caterium-app/tests/help-center.spec.mjs
pavlov346346-source c3cb44419b
Add human support form with fixed email recipient (#40)
Add Help contact form and PHP mail endpoint for support@katerion.ru, with validated Reply-To, explicit diagnostics consent, CSRF/origin checks, hashed rate limits and duplicate protection. Preserve drafts on error and avoid serializing customer data or SDK internals. Full PR QA passed in 35559317001; isolated PHP and 30 browser cases passed in 35559179372. Standard production gates unchanged. Publication checks do not send real mail; inbox receipt remains unverified. No training photo assets or unfinished training lifecycle changes.
2026-09-21 07:12:26 +03:00

108 lines
8.6 KiB
JavaScript
Raw 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=>['/help/knowledge-v1.json','/core/support-form.js'].includes(new URL(u).pathname))).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('Помощь');
});