caterium-app/tests/ui-stability.spec.mjs
pavlov346346-source 716a79c58d
Optional per-profile training catalog with reversible example visibility (#35)
Add Settings > Учебный каталог, off by default and available on ordinary writable profiles. Load ready example boxes, photos, TTKs and linked sample inventory additively; preserve own data, saved orders and edited examples when hiding or re-enabling. Respect company/profile scope, read-only permissions, failed downloads and tenant changes. Preserve the opened recipe guide across real catalog refreshes on iPhone. Integrated feature checks and full pull-request QA passed. Standard main QA and exact-asset production UI verification remain in place.
2026-09-20 12:18:40 +03:00

223 lines
17 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 fs from 'node:fs';
import {test,expect} from '@playwright/test';
const runtime=fs.readFileSync('public/app-runtime.js','utf8');
function moduleSource(name){const start=runtime.indexOf(`/* ===== MODULE: ${name} ===== */`),end=runtime.indexOf('/* ===== MODULE:',start+1);return runtime.slice(start,end<0?undefined:end);}
async function fixture(page,body){
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html; charset=utf-8',body:`<!doctype html><html><head><meta charset="utf-8"></head><body>${body}</body></html>`}));
await page.goto('/index.html');
await page.evaluate(()=>window.SunSafe={escapeHTML:v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])),insertBefore:(p,n,a)=>p.insertBefore(n,a)});
}
test('modal stacking respects existing layers and closing never steals input focus',async({page})=>{
await fixture(page,'<style>.modal{display:none;position:fixed;inset:0}.modal.on{display:block}</style><button id="opener">Открыть</button><input id="nextInput"><div id="first" class="modal" style="z-index:100050"><div class="dialog"><button>Первое окно</button></div></div><div id="second" class="modal"><div class="dialog"><button>Второе окно</button></div></div>');
await page.evaluate(()=>window.closeModal=id=>document.getElementById(id).classList.remove('on'));
const html=fs.readFileSync('public/index.html','utf8');
await page.addScriptTag({content:html.match(/<script id="sun-stability-motion-script">([\s\S]*?)<\/script>/)[1]});
await page.locator('#opener').focus();
await page.evaluate(()=>document.getElementById('first').classList.add('on'));
await expect(page.locator('#first button')).toBeFocused();
expect(await page.locator('#first').evaluate(el=>Number(getComputedStyle(el).zIndex))).toBeGreaterThanOrEqual(100050);
await page.evaluate(()=>document.getElementById('second').classList.add('on'));
await expect(page.locator('#second button')).toBeFocused();
expect(await page.evaluate(()=>Number(getComputedStyle(document.getElementById('second')).zIndex)>Number(getComputedStyle(document.getElementById('first')).zIndex))).toBe(true);
await page.evaluate(()=>{closeModal('second');closeModal('first');});
await page.locator('#nextInput').focus();
await page.waitForTimeout(250);
await expect(page.locator('#nextInput')).toBeFocused();
await page.evaluate(()=>document.getElementById('second').classList.add('on'));
await expect(page.locator('body')).toHaveClass(/sun-modal-open/);
await page.evaluate(()=>document.getElementById('second').remove());
await expect(page.locator('body')).not.toHaveClass(/sun-modal-open/);
});
test('settings settle without repeated DOM replacement and preserve focused controls',async({page})=>{
await fixture(page,`<header><nav><button>Настройки</button></nav></header><section id="enterprise-settings" class="on"><div class="catalog-head"></div><div class="enterprise-grid">
<section id="sunReceiptSettingsCard" class="enterprise-card"><h2>Настройки реквизитов товарного чека</h2><input value="Черновик"></section>
<section id="sunDefaultQrCard" class="enterprise-card"><h2>QR-код для бланка заказа</h2></section><section id="sunBlankSettingsCardV3" class="enterprise-card"><h2>Настройки бланка</h2></section>
<section id="sunCloudV2Card" class="enterprise-card"><h2>Профиль и аккаунт</h2></section><section class="enterprise-card"><h2>История изменений</h2></section></div></section>`);
await page.evaluate(()=>{window.sunOpenCategoryManager=()=>{};window.sunCatalogCategories=()=>[{id:0}];});
await page.addScriptTag({content:moduleSource('settings-polish-v1.js')+moduleSource('settings-tabs-v21.js')});
await page.getByRole('tab',{name:'Документы',exact:true}).click();
await page.locator('#sunReceiptSettingsCard input').focus();
await page.waitForTimeout(400);
await page.evaluate(()=>{window.changes=0;new MutationObserver(records=>window.changes+=records.length).observe(document.querySelector('#enterprise-settings'),{childList:true,subtree:true});});
await page.waitForTimeout(350);
expect(await page.evaluate(()=>window.changes)).toBe(0);
await expect(page.locator('#sunReceiptSettingsCard input')).toBeFocused();
await expect(page.locator('#sunReceiptSettingsCard input')).toHaveValue('Черновик');
await page.evaluate(()=>{window.sunCatalogCategories=()=>[{id:0},{id:1}];window.dispatchEvent(new Event('sun:catalog-categories-changed'));});
await expect(page.locator('.sun-settings-catalog-chip').first()).toHaveText('Всего: 2');
await expect(page.locator('#sunCatalogTabsSettingsCardV21')).toBeHidden();
});
test('unchanged cloud notifications preserve the theme input and do not repaint the theme',async({page})=>{
await fixture(page,'<section id="enterprise-settings" class="on"><div class="enterprise-grid"></div></section>');
await page.addScriptTag({url:'/core/brand-theme.js'});
const input=page.locator('[data-color-scope="sidebar"][data-color-key="background"] input[type="text"]');
await input.focus();
await page.evaluate(()=>{window.themeChanges=0;window.addEventListener('sunbrandthemechange',()=>window.themeChanges++);window.dispatchEvent(new Event('suncloudsync'));window.dispatchEvent(new Event('sun:cloud-state-applied'));window.dispatchEvent(new Event('resize'));});
await expect(input).toBeFocused();
expect(await page.evaluate(()=>window.themeChanges)).toBe(0);
});
async function subscriptionFixture(page){
await fixture(page,'<header><div class="brand"></div><nav></nav></header><section id="enterprise-settings"><div class="enterprise-grid"></div></section>');
await page.clock.install();
await page.evaluate(()=>{
window.pending=[];window.company='a';window.user='user';window.subscriptionEvents=[];
const client={rpc:(_,args)=>new Promise(resolve=>pending.push({id:args.p_workspace,resolve}))};
window.SunCloudV2={getClient:()=>client,getWorkspace:()=>company?{id:company}:null,getSession:()=>user?{user:{id:user}}:null};
window.addEventListener('sun:subscription-changed',e=>subscriptionEvents.push(e.detail.plan_name));
});
await page.addScriptTag({content:moduleSource('saas-v16.js')});
}
test('subscription responses from a previous account cannot restore its blocking window',async({page})=>{
await subscriptionFixture(page);
const result=await page.evaluate(async()=>{
const old=SunSaaSV16.refresh();company='b';window.dispatchEvent(new Event('sun:cloud-tenant-changing'));const next=SunSaaSV16.refresh();
const requested=pending.map(x=>x.id);
pending.find(x=>x.id==='b')?.resolve({data:{plan_name:'Company B',access_mode:'full',features:{}}});await next;
pending.find(x=>x.id==='a').resolve({data:{plan_name:'Company A',access_mode:'blocked',features:{}}});await old;
return {requested,name:SunSaaSV16.getSnapshot()?.plan_name,blocked:Boolean(document.getElementById('sunSaaSBlockedV16')),events:subscriptionEvents};
});
expect(result).toEqual({requested:['a','b'],name:'Company B',blocked:false,events:['Company B']});
});
test('subscription refresh preserves controls and its plans dialog is above the blocker',async({page})=>{
await subscriptionFixture(page);
await page.evaluate(async()=>{const p=SunSaaSV16.refresh();pending.shift().resolve({data:{plan_name:'Full',access_mode:'blocked',features:{}}});await p;});
await page.locator('[data-saas-show-plans]').click();
expect(await page.evaluate(()=>{const b=document.querySelector('#sunSaaSPlansModalV16 [data-close]'),r=b.getBoundingClientRect();return b.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2));})).toBe(true);
await page.locator('#sunSaaSPlansModalV16 [data-close]').click();
await page.evaluate(()=>{
const overlay=document.createElement('div');overlay.id='testSourceDialog';overlay.className='modal on';overlay.style.cssText='position:fixed;inset:0;z-index:100200;background:white';document.body.appendChild(overlay);SunSaaSV16.showPlans('client_offers');
});
await page.locator('#sunSaaSPlansModalV16 [data-close]').click();
await page.evaluate(()=>document.getElementById('testSourceDialog').remove());
await page.locator('[data-saas-show-plans]').focus();
await page.evaluate(async()=>{const p=SunSaaSV16.refresh();pending.shift().resolve({data:{plan_name:'Full',access_mode:'blocked',features:{}}});await p;});
await expect(page.locator('[data-saas-show-plans]')).toBeFocused();
await page.evaluate(()=>{company=null;user=null;window.dispatchEvent(new Event('sun:cloud-tenant-changing'));});
await expect(page.locator('#sunSaaSBlockedV16')).toHaveCount(0);
await expect(page.locator('#sunSaaSSettingsCardV16')).toHaveCount(0);
});
test('a delayed menu editor cannot move back into a section that was already left',async({page})=>{
await fixture(page,'<header><nav><button id="elsewhere">Заказы</button></nav></header><div id="editor" class="modal"><div class="dialog"><input id="name"></div></div>');
await page.evaluate(()=>{
localStorage.setItem('sunBoxes',JSON.stringify([{id:'one',name:'Тестовый бокс',price:100}]));
window.SunCloudV2={getSession:()=>({user:{id:'test'}}),hasPermission:()=>true};
window.modal=id=>document.getElementById(id).classList.add('on');window.closeModal=id=>document.getElementById(id).classList.remove('on');window.editBox=()=>window.modal('editor');
});
await page.addScriptTag({url:'/core/ops-ux-v1762.js'});
await page.evaluate(()=>{SunOpsUXV1762.openMenu();document.querySelector('[data-menu-item-v1762]').click();document.getElementById('elsewhere').click();});
await page.waitForTimeout(100);
await expect(page.locator('#editor > .dialog')).toHaveCount(1);
await expect(page.locator('#editor')).not.toHaveClass(/\bon\b/);
});
test('offer template picker is not rebuilt by the five-second maintenance timer',async({page})=>{
await fixture(page,'<button id="offerOpen" data-sun-offer-id="one">Предложение</button><div id="sunClientOfferModal" class="modal on"><div class="dialog"><div class="sun-offer-modal-head"></div></div></div>');
await page.evaluate(()=>{
localStorage.setItem('sunOrders',JSON.stringify([{id:'one',clientOfferTemplateId:'light'}]));
window.SunOfferTemplate={
get:()=>({id:'light',template:{id:'light',name:'Светлый'}}),
list:()=>[{id:'light',name:'Светлый'},{id:'editorial-grid',name:'Редакционный'}]
};
window.CateriumProposalPDF={resolveTemplate:id=>id,IDS:[]};
window.sunOpenClientOffer=()=>{};
window.sunOpenCurrentClientOffer=()=>{};
});
await page.addScriptTag({url:'/core/ux-fixes-v1764.js'});
await page.locator('#offerOpen').click();
const picker=page.locator('#sunOfferTemplateV1764');
await expect(picker).toBeVisible();
await expect(picker.locator('[data-v1764-offer-template]')).toHaveCount(2);
const before=await picker.evaluate(el=>({
first:el.querySelector('[data-v1764-offer-template]'),
html:el.innerHTML,
signature:el.dataset.sunV1764Signature
}));
await page.evaluate(()=>{
const picker=document.getElementById('sunOfferTemplateV1764');
window.offerPickerMutations=0;
new MutationObserver(records=>{window.offerPickerMutations+=records.filter(r=>r.type==='childList').length}).observe(picker,{childList:true,subtree:true});
});
await page.waitForTimeout(5300);
expect(await page.evaluate(()=>window.offerPickerMutations)).toBe(0);
expect(await picker.evaluate((el,before)=>el.innerHTML===before.html&&el.dataset.sunV1764Signature===before.signature,before)).toBe(true);
await picker.locator('[data-v1764-offer-template="editorial-grid"]').click();
await expect(picker.locator('[data-v1764-offer-template="editorial-grid"]')).toHaveClass(/\bon\b/);
const afterSelection=await page.evaluate(()=>window.offerPickerMutations);
expect(afterSelection).toBeGreaterThan(0);
await page.evaluate(()=>window.offerPickerMutations=0);
await page.waitForTimeout(5300);
expect(await page.evaluate(()=>window.offerPickerMutations)).toBe(0);
});
test('full app stays stable across settings, menu, calendar and delayed background refreshes',async({page})=>{
const errors=[];page.on('pageerror',e=>errors.push(String(e)));
const workspaceId='22222222-2222-4222-8222-222222222222',servedRpcs=new Set();
const expires=Math.floor(Date.now()/1000)+3600,user={id:'11111111-1111-4111-8111-111111111111',aud:'authenticated',role:'authenticated',email:'ui-test@example.invalid',app_metadata:{provider:'email'},user_metadata:{}};
const token=[{alg:'HS256',typ:'JWT'},{sub:user.id,role:'authenticated',aud:'authenticated',exp:expires,iat:expires-3600,aal:'aal1'},'test'].map(x=>typeof x==='string'?x:Buffer.from(JSON.stringify(x)).toString('base64url')).join('.');
const session={access_token:token,refresh_token:'test-refresh',token_type:'bearer',expires_in:3600,expires_at:expires,user};
await page.addInitScript(({session,workspaceId})=>{
localStorage.setItem('sb-usfjwhztqoopzzfmfbis-auth-token',JSON.stringify(session));
// This test starts inside an existing workspace. Onboarding/reload is covered
// separately; do not abort a background request by switching tenants here.
localStorage.setItem('sunCloudV2Config',JSON.stringify({workspaceId,localWorkspaceId:workspaceId,tenantStorageReady:true}));
},{session,workspaceId});
await page.route('https://**',r=>r.abort());
const handle=async route=>{
const url=new URL(route.request().url()),path=url.searchParams.get('__caterium_path')||url.pathname;
const requestHeaders=await route.request().allHeaders();
// Test-only CORS response: acknowledge the headers actually sent by the SDK,
// including metadata headers added by new SDK versions and browser preflights.
const headers={'access-control-allow-origin':requestHeaders.origin||'http://127.0.0.1:4173','access-control-allow-credentials':'true','access-control-allow-headers':requestHeaders['access-control-request-headers']||Object.keys(requestHeaders).join(','),'access-control-allow-methods':'GET,POST,OPTIONS'};
if(route.request().method()==='OPTIONS')return route.fulfill({status:204,headers});
let data=null;
if(path==='/auth/v1/user')data=user;
else if(path.endsWith('/sun_my_workspaces'))data=[{id:workspaceId,name:'Тестовая компания',role:'admin',is_active:true,permissions:{}}];
else if(path.endsWith('/sun_is_platform_admin'))data=false;
else if(path.endsWith('/caterium_trial_demo_status'))data={canInstall:false,canUpgrade:false};
else if(path.endsWith('/sun_subscription_snapshot'))data={plan_id:'full',plan_name:'Полный',status:'active',access_mode:'full',features:Object.fromEntries('orders calendar clients catalog_view catalog_edit production shopping stock routes mailings money stats_basic stats_advanced team suppliers print settings branding client_offers offer_templates backups audit users_manage'.split(' ').map(k=>[k,true]))};
else if(path.endsWith('/sun_fetch_app_state'))data=url.searchParams.get('select')==='revision'?{revision:1}:[{revision:1,payload:{format:'sun-cloud-v2',version:2,storage:{sunOrders:{t:'j',v:[]},sunBoxes:{t:'j',v:[]}}}}];
await route.fulfill({contentType:'application/json',body:JSON.stringify(data),headers});
if(path.includes('/rpc/'))servedRpcs.add(path.split('/').pop());
};
// Both configured transports stay within the mocked backend.
await page.route('**://api.caterium.ru/**',handle);
await page.route('**/api/index.php?**',handle);
await page.goto('/index.html',{waitUntil:'domcontentloaded'});
await expect(page.locator('body > header')).toBeVisible({timeout:20000});
await page.waitForFunction(()=>window.SunOpsUXV1762&&window.SunSaaSV16?.getSnapshot());
await page.waitForFunction(()=>window.CateriumTrialDemo&&window.CateriumTrainingCatalog);
expect(await page.evaluate(()=>CateriumTrainingCatalog.enabled())).toBe(false);
expect(servedRpcs.has('caterium_trial_demo_status')).toBe(false);
const nav=page.locator('header nav');
for(const name of ['Настройки','Меню','Календарь','Заказы','Настройки']){
await nav.getByRole('button',{name,exact:true}).click();
await expect(page.locator('body > .view.on')).toHaveCount(1);
await expect(page.locator('#sunCloudAuthGateV3')).toHaveCount(0);
await expect(page.locator('.modal.on')).toHaveCount(0);
}
await page.getByRole('tab',{name:'Оформление',exact:true}).click();
const input=page.locator('[data-color-scope="sidebar"][data-color-key="background"] input[type="text"]');
await input.focus();
await page.waitForTimeout(500);
await page.evaluate(()=>{
window.settingsMutations=[];
new MutationObserver(records=>{for(const r of records)settingsMutations.push(r.target.id||r.target.parentElement?.id||r.target.nodeName)}).observe(document.querySelector('#enterprise-settings'),{childList:true,subtree:true});
});
await page.waitForTimeout(1100);
expect(await page.evaluate(()=>settingsMutations)).toEqual([]);
await page.evaluate(()=>window.dispatchEvent(new Event('sun:cloud-state-applied')));
await page.waitForTimeout(600);
await expect(input).toBeFocused();
await expect(page.locator('.sun-settings-tab.on')).toHaveText('Оформление');
expect(errors).toEqual([]);
});