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.
223 lines
17 KiB
JavaScript
223 lines
17 KiB
JavaScript
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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([]);
|
||
});
|