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=``;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?`
`:''}${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-код, показанный здесь.
${qr?`
`:''}${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}}
]