caterium-app/tests/ui-stability.spec.mjs
2026-09-18 21:23:36 +03:00

170 lines
14 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 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('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 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(value=>localStorage.setItem('sb-usfjwhztqoopzzfmfbis-auth-token',JSON.stringify(value)),session);
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 headers={'access-control-allow-origin':'*','access-control-allow-headers':'authorization,apikey,content-type,x-client-info','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:'22222222-2222-4222-8222-222222222222',name:'Тестовая компания',role:'admin',is_active:true,permissions:{}}];
else if(path.endsWith('/sun_is_platform_admin'))data=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:[]}}}}];
return route.fulfill({contentType:'application/json',body:JSON.stringify(data),headers});
};
// The real transport can use either configured proxy while the first document
// reloads after workspace discovery. Both belong to this local server fixture.
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());
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([]);
});