From 8b25918e3026119a307a4dd94cf5e13d9056c727 Mon Sep 17 00:00:00 2001 From: pavlov346346-source Date: Sun, 20 Sep 2026 20:08:46 +0300 Subject: [PATCH] Fix reopening incomplete developer MFA setup (#38) Recover only unverified Caterium Developer TOTP factors from the full factor list. Preserve verified MFA, reuse the in-memory QR after closing the dialog, serialize enrollment and retain correct-code/AAL2 checks. Complete pull-request QA passed in run 35518429693. Standard main QA and publication gates remain unchanged. --- public/app-runtime.js | 127 ++++++++++++++++++++++++++--------- public/service-worker.js | 2 +- tests/developer-mfa.spec.mjs | 70 +++++++++++++++++++ tests/playwright.config.mjs | 4 +- 4 files changed, 167 insertions(+), 36 deletions(-) create mode 100644 tests/developer-mfa.spec.mjs diff --git a/public/app-runtime.js b/public/app-runtime.js index 8b16f70..d91a86c 100644 --- a/public/app-runtime.js +++ b/public/app-runtime.js @@ -5475,47 +5475,108 @@ window.SUN_LEGACY_CATALOG_V175=[]; function modal(title,html){$('sunDevModalV22')?.remove();const m=document.createElement('div');m.className='sun-dev-modal';m.id='sunDevModalV22';m.innerHTML=`

${esc(title)}

${html}
`;document.body.appendChild(m);const close=()=>m.remove();m.addEventListener('click',e=>{if(e.target===m||e.target.closest('[data-close]'))close()});return {m,body:$('#sunDevModalBody'),close}} function confirmDanger(text){return confirm(text)} - async function ensureDeveloperMfa(){ - const c=client();if(!c?.auth?.mfa)throw new Error('Supabase MFA недоступна.'); - const readAal=async()=>{const a=await c.auth.mfa.getAuthenticatorAssuranceLevel();if(a.error)throw a.error;return a.data||{}}; - const waitAal2=async()=>{for(let i=0;i<12;i++){const a=await readAal();if(a.currentLevel==='aal2')return true;await new Promise(r=>setTimeout(r,180))}return false}; - const initial=await readAal();if(initial.currentLevel==='aal2')return true; - const listed=await c.auth.mfa.listFactors();if(listed.error)throw listed.error; - let factor=(listed.data?.totp||[]).find(f=>f.status==='verified');let enrollment=null; - if(!factor){ - for(const f of (listed.data?.totp||[]).filter(x=>x.status!=='verified')){ - try{const u=await c.auth.mfa.unenroll({factorId:f.id});if(u.error&&!/not found/i.test(String(u.error.message||'')))throw u.error}catch(e){console.warn('[Developer MFA] reset factor',e)} + let developerMfaPending=null,developerMfaEnrollment=null; + function ensureDeveloperMfa(){ + const c=client(),uid=session()?.user?.id; + if(!c?.auth?.mfa||!uid)return Promise.reject(new Error('Войдите в аккаунт разработчика.')); + if(developerMfaPending?.client===c&&developerMfaPending.uid===uid)return developerMfaPending.promise; + const task={client:c,uid,promise:null};developerMfaPending=task; + const run=()=>runDeveloperMfa(c,uid); + task.promise=(navigator.locks?.request?navigator.locks.request(`caterium-developer-mfa:${uid}`,{ifAvailable:true},lock=>{ + if(!lock){toast('Подтверждение разработчика уже открыто в другой вкладке. Завершите его там или закройте то окно.','info');return false;} + return run(); + }):run()).finally(()=>{if(developerMfaPending===task)developerMfaPending=null;}); + return task.promise; + } + async function runDeveloperMfa(c,uid){ + const current=()=>client()===c&&session()?.user?.id===uid; + const check=()=>{if(!current())throw new Error('Аккаунт изменился. Откройте кабинет разработчика заново.');}; + const readAal=async()=>{check();const a=await c.auth.mfa.getAuthenticatorAssuranceLevel();check();if(a.error)throw a.error;return a.data||{};}; + const list=async()=>{ + check();const r=await c.auth.mfa.listFactors();check();if(r.error)throw r.error; + // Supabase .totp contains VERIFIED factors only. Abandoned enrollments + // are in .all, and must not be confused with another authenticator. + return [...new Map([...(r.data?.all||[]),...(r.data?.totp||[])].map(f=>[f.id,f])).values()]; + }; + const totp=f=>f.factor_type==='totp'||f.type==='totp'; + const verified=rows=>rows.find(f=>totp(f)&&f.status==='verified'); + const abandoned=f=>totp(f)&&f.status==='unverified'&&f.friendly_name==='Caterium Developer'; + const waitAal2=async()=>{for(let i=0;i<12;i++){if((await readAal()).currentLevel==='aal2')return true;await new Promise(r=>setTimeout(r,180));}return false;}; + if((await readAal()).currentLevel==='aal2')return true; + let rows=await list(),factor=verified(rows),enrollment=null; + const remembered=developerMfaEnrollment; + if(factor)developerMfaEnrollment=null; + else if(remembered?.client===c&&remembered.uid===uid&&rows.some(f=>f.id===remembered.data.id&&abandoned(f))){ + enrollment=remembered.data;factor=enrollment; + }else{ + developerMfaEnrollment=null; + for(const old of rows.filter(abandoned)){ + // Refresh immediately before deleting: never remove a factor that was + // confirmed in another session, nor a phone/passkey/other app factor. + rows=await list();factor=verified(rows);if(factor)break; + if(!rows.some(f=>f.id===old.id&&abandoned(f)))continue; + const reset=await c.auth.mfa.unenroll({factorId:old.id});check(); + if(reset.error&&reset.error.code!=='mfa_factor_not_found'&&!/factor.*not found/i.test(reset.error.message||'')){ + throw new Error('Не удалось восстановить незавершённую настройку 2FA. Проверьте соединение и откройте раздел ещё раз.'); + } + } + if(!factor){ + rows=await list();factor=verified(rows); + if(!factor){ + const enr=await c.auth.mfa.enroll({factorType:'totp',friendlyName:'Caterium Developer'});check(); + if(enr.error){ + // A second device may have completed enrollment while this one + // was waiting. Reuse only its verified factor; never bypass MFA. + rows=await list();factor=verified(rows); + if(!factor)throw new Error(/already exists|friendly name/i.test(enr.error.message||'')?'Настройка 2FA начата в другом окне. Закройте его и повторно откройте кабинет разработчика.':enr.error.message||'Не удалось создать QR-код. Повторите попытку.'); + }else{ + if(!enr.data?.id||!enr.data?.totp?.qr_code)throw new Error('Сервер не вернул QR-код. Повторно откройте кабинет разработчика.'); + enrollment=enr.data;factor=enrollment; + developerMfaEnrollment={client:c,uid,data:enrollment}; + } + } } - const enr=await c.auth.mfa.enroll({factorType:'totp',friendlyName:'Caterium Developer'});if(enr.error)throw enr.error;factor=enr.data;enrollment=enr.data; } - const firstSetup=Boolean(enrollment),qr=enrollment?.totp?.qr_code||'',secret=enrollment?.totp?.secret||''; - const ui=modal(firstSetup?'Подключение 2FA разработчика':'Подтверждение разработчика',firstSetup?`

Отсканируйте этот QR-код приложением-аутентификатором. Затем введите текущие 6 цифр и нажмите Подтвердить и открыть.

${qr?`QR для 2FA`:''}${secret?`

Если QR не сканируется, добавьте ключ вручную:

${esc(secret)}
`:''}
`:`

Введите текущий 6-значный код из приложения-аутентификатора.

`); - return await new Promise(resolve=>{ - let settled=false,busy=false; - const finish=v=>{if(settled)return;settled=true;try{ui.close()}catch(_){}resolve(v)}; - const showError=(e,notify=true)=>{const msg=e?.message||String(e||'Не удалось подтвердить код.');const box=$('sunDevMfaError');if(box)box.textContent=msg;if(notify)toast(msg,'error',6500)}; + check(); + const firstSetup=Boolean(enrollment),rawQr=enrollment?.totp?.qr_code||''; + const qr=rawQr.trim().startsWith('Отсканируйте QR-код приложением-аутентификатором, затем введите 6 цифр из этого приложения.

Закрыли окно случайно? Откройте его снова — настройку можно продолжить. После перезагрузки используйте новый QR-код, показанный здесь.

${qr?`QR для 2FA`:''}${secret?`

Или добавьте ключ вручную:

${esc(secret)}
`:''}`:'

Введите текущий 6-значный код из приложения-аутентификатора. Подтверждённая защита уже подключена.

'; + const ui=modal(firstSetup?'Подключение 2FA разработчика':'Подтверждение разработчика',`${intro}
`); + return new Promise(resolve=>{ + let settled=false,busy=false,focusTimer=0; + const inp=ui.m.querySelector('#sunDevMfaCode'),btn=ui.m.querySelector('#sunDevMfaVerify'),box=ui.m.querySelector('#sunDevMfaError'); + const finish=value=>{if(settled)return;settled=true;clearTimeout(focusTimer);observer.disconnect();window.removeEventListener('sun:cloud-permissions-changed',accountChanged);ui.close();resolve(value);}; + const accountChanged=()=>{if(!current()){developerMfaEnrollment=null;finish(false);}}; + const observer=new MutationObserver(()=>{if(!ui.m.isConnected)finish(false);});observer.observe(document.body,{childList:true}); + window.addEventListener('sun:cloud-permissions-changed',accountChanged); + const showError=e=>{box.textContent=e?.message||String(e||'Не удалось подтвердить код.');}; const verify=async()=>{ - if(busy)return; - const code=String(document.getElementById('sunDevMfaCode')?.value||'').replace(/\D/g,''); - if(code.length!==6){showError(new Error('Введите ровно 6 цифр из приложения-аутентификатора.'),false);return} - busy=true;const btn=$('sunDevMfaVerify');if(btn){btn.disabled=true;btn.textContent='Проверяю…'};const box=$('sunDevMfaError');if(box)box.textContent='Проверяю код…'; + if(busy||settled)return; + if(!current()){accountChanged();return;} + const code=String(inp.value||'').replace(/\D/g,''); + if(code.length!==6){showError(new Error('Введите ровно 6 цифр из приложения-аутентификатора.'));return;} + busy=true;btn.disabled=true;btn.textContent='Проверяю…';box.textContent='Проверяю код…'; try{ let vr; - if(typeof c.auth.mfa.challengeAndVerify==='function') vr=await c.auth.mfa.challengeAndVerify({factorId:factor.id,code}); - else {const ch=await c.auth.mfa.challenge({factorId:factor.id});if(ch.error)throw ch.error;vr=await c.auth.mfa.verify({factorId:factor.id,challengeId:ch.data.id,code})} - if(vr?.error)throw vr.error; + if(typeof c.auth.mfa.challengeAndVerify==='function')vr=await c.auth.mfa.challengeAndVerify({factorId:factor.id,code}); + else{const ch=await c.auth.mfa.challenge({factorId:factor.id});check();if(ch.error)throw ch.error;vr=await c.auth.mfa.verify({factorId:factor.id,challengeId:ch.data.id,code});} + check();if(settled)return;if(vr?.error)throw vr.error; + developerMfaEnrollment=null; if(!await waitAal2()){ - try{await c.auth.refreshSession()}catch(_){} - if(!await waitAal2())throw new Error('Код принят, но сессия разработчика не получила AAL2. Выйдите из аккаунта и войдите снова, затем повторите код.'); + check();const refreshed=await c.auth.refreshSession();check();if(refreshed?.error)throw refreshed.error; + if(!await waitAal2())throw new Error('Код принят, но сессия ещё не подтверждена. Войдите в аккаунт заново и введите текущий код.'); } - toast('2FA подтверждена. Открываю кабинет разработчика…','success',2800); - finish(true); - }catch(e){busy=false;if(btn){btn.disabled=false;btn.textContent='Подтвердить и открыть'};showError(e)} + if(!settled){toast('2FA подтверждена. Открываю кабинет разработчика…','success',2800);finish(true);} + }catch(e){if(!current()){accountChanged();return;}if(!settled){busy=false;btn.disabled=false;btn.textContent='Подтвердить и открыть';showError(e);}} }; - const b=document.getElementById('sunDevMfaVerify'),inp=document.getElementById('sunDevMfaCode');if(b)b.addEventListener('click',verify);if(inp)inp.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();verify()}}); - ui.m.addEventListener('click',e=>{if((e.target===ui.m||e.target.closest('[data-close]'))&&!busy)finish(false)}); - setTimeout(()=>inp?.focus(),60); - }) + btn.addEventListener('click',verify);inp.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();verify();}}); + // Capture before the generic modal's close handler. Closing during a + // verification request must not remove the form or leave a pending lock. + ui.m.addEventListener('click',e=>{if(e.target===ui.m||e.target.closest('[data-close]')){e.preventDefault();e.stopImmediatePropagation();if(!busy)finish(false);}},true); + ui.m.addEventListener('keydown',e=>{if(e.key==='Escape'){e.preventDefault();e.stopImmediatePropagation();if(!busy)finish(false);}},true); + focusTimer=setTimeout(()=>{if(!settled)inp.focus();},60); + }); } async function renderOverview(body){const d=await rpc('sun_dev_dashboard');body.innerHTML=`
Компании${Number(d.companies||0)}
Аккаунты${Number(d.accounts||0)}
Активные подписки${Number(d.active_subscriptions||0)}
Ограниченные компании${Number(d.restricted_companies||0)}
Ошибки за 24 ч.${Number(d.errors_24h||0)}
Копии за 24 ч.${Number(d.backups_24h||0)}
Участники компаний${Number(d.memberships||0)}
Размер БД${esc(d.database_size||'—')}

Платформа

Версия приложения: 17.6.0

PostgreSQL: ${esc(d.postgres_version||'—')}

Последнее изменение рабочей базы: ${fmtDate(d.last_state_at)}

Безопасность

MFA AAL2 Кабинет разработчика защищён двухфакторной авторизацией.

Права разработчика проверяются сервером через sun_platform_admins.

`;try{const r=await fetch('release-manifest.json',{cache:'no-store'});if(r.ok){const m=await r.json();$('#sunDevReleaseVersion').textContent=m.version||'17.6.0'}}catch(_){} } diff --git a/public/service-worker.js b/public/service-worker.js index 76bfd11..39becce 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,4 +1,4 @@ -const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage-employee-session'; +const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage-employee-session-mfa-recovery'; const VERSION='20260918-ui-stability'; const CORE=[ './core/banquet-client-menu.js?v=20260920-onepage', diff --git a/tests/developer-mfa.spec.mjs b/tests/developer-mfa.spec.mjs new file mode 100644 index 0000000..74d48b6 --- /dev/null +++ b/tests/developer-mfa.spec.mjs @@ -0,0 +1,70 @@ +import fs from 'node:fs'; +import {test,expect} from '@playwright/test'; +const runtime=fs.readFileSync(new URL('../public/app-runtime.js',import.meta.url),'utf8'); +const start=runtime.indexOf(' let developerMfaPending=null'); +if(start<0)throw new Error('Developer MFA recovery is not integrated'); +const implementation=runtime.slice(start,runtime.indexOf('\n async function renderOverview',start)); +const pending={id:'abandoned',factor_type:'totp',friendly_name:'Caterium Developer',status:'unverified'}; +async function fixture(page,options={}){ + await page.setContent(''); + await page.evaluate(options=>{ + window.testUid='developer';window.mfaServer={rows:options.rows||[],aal:'aal1',enrolls:0,deletes:[],lists:0,verifies:0,results:[],errors:[],...options}; + const server=window.mfaServer; + window.testClient={auth:{refreshSession:async()=>({error:null}),mfa:{ + getAuthenticatorAssuranceLevel:async()=>({data:{currentLevel:server.aal}}), + listFactors:async()=>{server.lists++;if(server.verifyOnList===server.lists)server.rows[0].status='verified';return {data:{all:structuredClone(server.rows),totp:structuredClone(server.rows.filter(f=>f.factor_type==='totp'&&f.status==='verified'))}};}, + unenroll:async({factorId})=>{server.deletes.push(factorId);if(server.resetError)return {error:{message:'Network failure'}};const factor=server.rows.find(f=>f.id===factorId);if(factor?.status==='verified')throw new Error('Must never remove verified MFA');server.rows=server.rows.filter(f=>f.id!==factorId);return {error:null};}, + enroll:async()=>{server.enrolls++;if(server.rows.some(f=>f.friendly_name==='Caterium Developer'))return {error:{message:'A factor with this friendly name already exists'}};const f={id:'new-'+server.enrolls,factor_type:'totp',friendly_name:'Caterium Developer',status:'unverified'};server.rows.push(f);return {data:{...f,totp:{qr_code:'',secret:'SYNTHETIC-TEST-KEY'}}};}, + challengeAndVerify:async({factorId,code})=>{server.verifies++;if(code!=='123456')return {error:{message:'Неверный код'}};if(server.delayVerify)await new Promise(r=>window.finishVerification=r);server.rows.find(f=>f.id===factorId).status='verified';server.aal='aal2';return {error:null};} + }}}; + },options); + await page.addScriptTag({content:`(()=>{ + const client=()=>window.testClient,session=()=>({user:{id:window.testUid}}),toast=()=>{}; + const esc=x=>String(x??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + function modal(title,html){document.getElementById('sunDevModalV22')?.remove();const m=document.createElement('div');m.id='sunDevModalV22';m.innerHTML='

'+esc(title)+'

'+html;document.body.append(m);const close=()=>m.remove();m.addEventListener('click',e=>{if(e.target===m||e.target.closest('[data-close]'))close();});return {m,close};} + ${implementation} + window.openTestMfa=()=>ensureDeveloperMfa().then(v=>mfaServer.results.push(v),e=>mfaServer.errors.push(e.message)); + })();`}); +} +async function open(page){await page.evaluate(()=>{void openTestMfa();});await expect(page.locator('#sunDevMfaCode')).toBeVisible();} +const counts=page=>page.evaluate(()=>({enrolls:mfaServer.enrolls,deletes:mfaServer.deletes,verifies:mfaServer.verifies})); + +test('abandoned enrollment in all is recovered without touching unrelated factors',async({page})=>{ + await fixture(page,{rows:[pending,{...pending,id:'other-app',friendly_name:'Other app'},{...pending,id:'phone',factor_type:'phone',friendly_name:'My phone'}]});await open(page); + expect(await counts(page)).toEqual({enrolls:1,deletes:['abandoned'],verifies:0}); + await expect(page.locator('.sun-dev-mfa-qr')).toHaveAttribute('src',/^data:image\/svg\+xml/); +}); +test('closing and reopening keeps the QR and concurrent opens share one enrollment',async({page})=>{ + await fixture(page);await open(page);const qr=await page.locator('.sun-dev-mfa-qr').getAttribute('src'); + await page.locator('[data-close]').click();await expect(page.locator('#sunDevMfaCode')).toHaveCount(0); + await page.evaluate(()=>{for(let i=0;i<5;i++)void openTestMfa();});await expect(page.locator('#sunDevMfaCode')).toHaveCount(1); + await expect(page.locator('.sun-dev-mfa-qr')).toHaveAttribute('src',qr);expect((await counts(page)).enrolls).toBe(1); + await page.locator('#sunDevMfaCode').fill('123456');await page.locator('#sunDevMfaVerify').click(); + await expect.poll(()=>page.evaluate(()=>mfaServer.results)).toEqual([false,true,true,true,true,true]);expect((await counts(page)).verifies).toBe(1); +}); +test('verified MFA stays enrolled and access still requires a correct code and AAL2',async({page})=>{ + await fixture(page,{rows:[{...pending,status:'verified'}]});await open(page);await expect(page.locator('.sun-dev-mfa-qr')).toHaveCount(0); + await page.locator('#sunDevMfaCode').fill('999999');await page.locator('#sunDevMfaVerify').click();await expect(page.locator('#sunDevMfaError')).toContainText('Неверный код'); + expect(await page.evaluate(()=>mfaServer.results)).toEqual([]); + await page.locator('#sunDevMfaCode').fill('123456');await page.locator('#sunDevMfaVerify').click();await expect.poll(()=>page.evaluate(()=>mfaServer.results)).toEqual([true]); + expect(await counts(page)).toEqual({enrolls:0,deletes:[],verifies:2}); +}); +test('failed cleanup stops enrollment and never reports developer access',async({page})=>{ + await fixture(page,{rows:[pending],resetError:true});await page.evaluate(()=>{void openTestMfa();}); + await expect.poll(()=>page.evaluate(()=>mfaServer.errors.length)).toBe(1); + expect((await counts(page)).enrolls).toBe(0);expect(await page.evaluate(()=>mfaServer.results)).toEqual([]);await expect(page.locator('#sunDevMfaCode')).toHaveCount(0); +}); +test('a factor verified during recovery is not removed',async({page})=>{ + await fixture(page,{rows:[pending],verifyOnList:2});await open(page); + expect(await counts(page)).toEqual({enrolls:0,deletes:[],verifies:0});await expect(page.locator('.sun-dev-mfa-qr')).toHaveCount(0); +}); +test('closing during verification cannot orphan the modal or issue another request',async({page})=>{ + await fixture(page,{delayVerify:true});await open(page);await page.locator('#sunDevMfaCode').fill('123456');await page.locator('#sunDevMfaVerify').click(); + await page.locator('[data-close]').click();await expect(page.locator('#sunDevMfaCode')).toBeVisible();await expect(page.locator('#sunDevMfaVerify')).toBeDisabled(); + await page.evaluate(()=>finishVerification());await expect.poll(()=>page.evaluate(()=>mfaServer.results)).toEqual([true]);await expect(page.locator('#sunDevMfaCode')).toHaveCount(0); +}); +test('account change closes the sensitive QR and cannot grant access to another account',async({page})=>{ + await fixture(page);await open(page);await page.evaluate(()=>{testUid='another-user';window.dispatchEvent(new Event('sun:cloud-permissions-changed'));}); + await expect.poll(()=>page.evaluate(()=>mfaServer.results)).toEqual([false]);await expect(page.locator('.sun-dev-mfa-qr')).toHaveCount(0); + expect(implementation).not.toMatch(/localStorage|sessionStorage/); +}); diff --git a/tests/playwright.config.mjs b/tests/playwright.config.mjs index fdb5ed0..c47374d 100644 --- a/tests/playwright.config.mjs +++ b/tests/playwright.config.mjs @@ -2,13 +2,13 @@ import { defineConfig, devices } from '@playwright/test'; import {fileURLToPath} from 'node:url'; export default defineConfig({ testDir:'.', - testMatch:['employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'], + testMatch:['developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'], timeout:30000, use:{baseURL:'http://127.0.0.1:4173'}, webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true}, projects:[ {name:'iphone-pdf',testMatch:['proposal-quality.spec.mjs'],grep:/all six selections|transparent wide|an actual offer downloads/,use:{...devices['iPhone 13'],serviceWorkers:'block'}}, - {name:'iphone-webkit',testMatch:['employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}}, + {name:'iphone-webkit',testMatch:['developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}}, {name:'desktop',use:{...devices['Desktop Chrome']}}, {name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}} ]