caterium-app/tests/help-center.spec.mjs
pavlo d7d6f6ff38 Add AI support assistant to the Help dialog
The Поддержка tab opens an assistant that answers how-to questions from
the handbook. The chat is a sandboxed iframe on the assistant's own
origin and is only loaded after an explicit click, so Help and its search
stay local and no third-party script runs inside the app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 18:27:47 +03:00

108 lines
8.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {test,expect} from '@playwright/test';
import fs from 'node:fs';
const kb=JSON.parse(fs.readFileSync('public/help/knowledge-v1.json','utf8'));
async function fixture(page){
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html; charset=utf-8',body:'<!doctype html><html><body><header><nav></nav></header><main>Рабочая база</main></body></html>'}));
await page.goto('/index.html');await page.addStyleTag({url:'/core/help-center.css'});await page.addScriptTag({url:'/core/help-center.js'});
}
test('manual search, categories and related instructions work without sending company data',async({page})=>{
expect(new Set(kb.articles.map(a=>a.id)).size).toBe(kb.articles.length);
expect(kb.articles.every(a=>a.related.every(id=>kb.articles.some(b=>b.id===id)))).toBe(true);
await fixture(page);await page.evaluate(()=>localStorage.setItem('sunOrders','private-order-canary'));
const requests=[];page.on('request',r=>requests.push(r.url()));
await page.getByRole('button',{name:'Помощь',exact:true}).click();
await expect(page.locator('#ctHelpResults details')).toHaveCount(kb.articles.length);
await page.getByRole('searchbox').fill('предоплата');
await expect(page.locator('[data-article="payment"]')).toBeVisible();
await page.locator('[data-article="payment"] summary').click();await expect(page.locator('[data-article="payment"]')).toContainText('синий');
await page.locator('[data-article="payment"] [data-help-article="delivery"]').click();await expect(page.locator('[data-article="delivery"]')).toHaveAttribute('open','');
await page.getByRole('combobox',{name:'Категория',exact:true}).selectOption('Меню и ТТК');await expect(page.locator('#ctHelpResults details')).toHaveCount(3);
await page.getByRole('searchbox').fill('<img src=x onerror=alert(1)>');await expect(page.locator('#ctHelpStatus')).toContainText('Ничего не найдено');
expect(requests.every(u=>u.includes('/help/knowledge-v1.json'))).toBe(true);
expect(await page.evaluate(()=>localStorage.sunOrders)).toBe('private-order-canary');
});
test('support assistant connects only after an explicit click, inside a sandboxed iframe',async({page})=>{
await fixture(page);
const external=[];page.on('request',r=>{if(!r.url().startsWith('http://127.0.0.1'))external.push(r.url())});
await page.route('https://ai-staff-alpha.vercel.app/**',r=>r.fulfill({contentType:'text/html',body:'<!doctype html><title>stub</title>'}));
await page.getByRole('button',{name:'Помощь',exact:true}).click();
await expect(page.locator('#ctHelpResults details')).toHaveCount(kb.articles.length);
await page.getByRole('button',{name:'Поддержка',exact:true}).click();await expect(page.locator('#ctHelpSupport')).toContainText('внешний сервис');
await page.getByRole('button',{name:'Как отметить оплату?',exact:true}).click();await expect(page.getByRole('searchbox')).toHaveValue('оплата');
expect(external).toEqual([]);
await page.getByRole('button',{name:'Поддержка',exact:true}).click();
await page.getByRole('button',{name:'Задать вопрос помощнику',exact:true}).click();
const frame=page.locator('#ctHelpBot iframe');
await expect(frame).toHaveAttribute('src',/^https:\/\/ai-staff-alpha\.vercel\.app\/embed\/[0-9a-f-]{36}$/);
await expect(frame).toHaveAttribute('referrerpolicy','no-referrer');
const sandbox=await frame.getAttribute('sandbox');expect(sandbox).toContain('allow-scripts');expect(sandbox).not.toMatch(/allow-top-navigation|allow-forms|allow-modals/);
expect(await page.locator('#ctHelpDialog').evaluate(el=>el.getBoundingClientRect().width<=innerWidth&&el.scrollWidth<=el.clientWidth)).toBe(true);
await page.keyboard.press('Escape');await expect(page.locator('#ctHelpDialog')).not.toBeVisible();await expect(page.getByRole('button',{name:'Помощь',exact:true})).toBeFocused();
});
test('failed handbook load provides a working retry',async({page})=>{
let count=0;await page.route('**/help/knowledge-v1.json*',r=>++count===1?r.fulfill({status:503,body:''}):r.continue());
await fixture(page);await page.getByRole('button',{name:'Помощь',exact:true}).click();await expect(page.locator('#ctHelpStatus')).toContainText('Не удалось');
await page.getByRole('button',{name:'Повторить загрузку',exact:true}).click();await expect(page.locator('#ctHelpResults details')).toHaveCount(kb.articles.length);
});
test('full app exposes help on login without exposing the workspace',async({page})=>{
await page.route('**://api.caterium.ru/**',r=>r.abort());await page.route('**://*.supabase.co/**',r=>r.abort());
await page.goto('/index.html',{waitUntil:'domcontentloaded'});
await page.getByRole('button',{name:'Помощь со входом',exact:true}).click();
await expect(page.locator('[data-article="iphone"]')).toBeVisible();await expect(page.locator('body > header')).toBeHidden();
await page.getByRole('button',{name:'Закрыть помощь',exact:true}).click();await expect(page.locator('#sunGateEmailV3')).toBeVisible();
});
test('Help uses a question-circle mask with the native sidebar icon geometry and colors',async({page})=>{
// No production account or network is needed to inspect the real navigation styles.
await page.route('https://**',r=>r.abort());
await page.route('**/api/index.php*',r=>r.fulfill({status:503,contentType:'application/json',body:'{}'}));
await page.goto('/index.html',{waitUntil:'domcontentloaded'});
const help=page.locator('#ctHelpNav');
await expect(help).toHaveClass(/sun-nav-button/);
await page.locator('header nav .nav-settings').waitFor({state:'attached'});
// The narrow layout uses the common compact markers, not desktop SVG masks.
// Check its native geometry before exercising the actual left sidebar in each engine.
if(page.viewportSize().width<=900){
const sizes=await help.evaluate(el=>{
const size=node=>{const s=getComputedStyle(node,'::before');return {width:s.width,height:s.height,margin:s.margin};};
return {help:size(el),peer:size(document.querySelector('header nav .nav-settings'))};
});
expect(sizes.help).toEqual(sizes.peer);
await page.setViewportSize({width:1440,height:900});
}
await expect(page.locator('body')).toHaveClass(/sun-enterprise-sidebar/);
await expect.poll(()=>help.evaluate(el=>getComputedStyle(el,'::before').getPropertyValue('mask-image')||getComputedStyle(el,'::before').getPropertyValue('-webkit-mask-image'))).toContain('data:image/svg+xml');
const result=await help.evaluate(el=>{
const peer=document.querySelector('header nav .nav-settings');
const pseudo=node=>getComputedStyle(node,'::before');
const mask=pseudo(el).getPropertyValue('mask-image')||pseudo(el).getPropertyValue('-webkit-mask-image');
const dataUrl=mask.match(/url\(["']?(data:image\/svg\+xml,[^"')]+)["']?\)/)?.[1];
const svg=dataUrl?new DOMParser().parseFromString(decodeURIComponent(dataUrl.slice(dataUrl.indexOf(',')+1)),'image/svg+xml'):null;
const geometry=node=>{
const s=pseudo(node);
return {width:s.width,height:s.height,marginRight:s.marginRight,background:s.backgroundColor,opacity:s.opacity,maskSize:s.getPropertyValue('mask-size')||s.getPropertyValue('-webkit-mask-size')};
};
const normal={help:geometry(el),peer:geometry(peer)};
const hadHelp=el.classList.contains('on'),hadPeer=peer.classList.contains('on');
el.classList.add('on');peer.classList.add('on');
const active={help:geometry(el),peer:geometry(peer)};
el.classList.toggle('on',hadHelp);peer.classList.toggle('on',hadPeer);
return {normal,active,viewBox:svg?.documentElement.getAttribute('viewBox'),fill:svg?.documentElement.getAttribute('fill'),stroke:svg?.documentElement.getAttribute('stroke-width'),circle:svg?.querySelector('circle')?.getAttribute('r'),question:svg?.querySelector('path')?.getAttribute('d')};
});
expect(result.viewBox).toBe('0 0 24 24');expect(result.fill).toBe('none');expect(result.stroke).toBe('1.9');expect(result.circle).toBe('9');expect(result.question).toContain('M12 17h.01');
expect(result.normal.help).toEqual(result.normal.peer);expect(result.active.help).toEqual(result.active.peer);
// The shared legacy flex basis reserves 24px, while the mask itself is square.
// Validate the actual drawn mask, not an assumed square layout slot.
const [maskWidth,maskHeight=maskWidth]=result.normal.help.maskSize.split(/\s+/).map(parseFloat);
expect(maskWidth).toBeGreaterThanOrEqual(16);expect(maskWidth).toBe(maskHeight);
expect(maskWidth).toBeLessThanOrEqual(parseFloat(result.normal.help.width));
expect(maskHeight).toBeLessThanOrEqual(parseFloat(result.normal.help.height));
await expect(help.locator('svg,img')).toHaveCount(0);
await expect(help).toHaveText('Помощь');
});