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.
This commit is contained in:
pavlov346346-source 2026-09-20 20:08:46 +03:00 committed by GitHub
parent 1e644e6dfb
commit 8b25918e30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 167 additions and 36 deletions

View File

@ -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=`<div class="sun-dev-modal-card"><div class="sun-dev-modal-head"><h2>${esc(title)}</h2><button class="sun-dev-modal-close" type="button" data-close>×</button></div><div id="sunDevModalBody" style="margin-top:14px">${html}</div></div>`;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;
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){
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)}
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?`<p>Отсканируйте этот QR-код приложением-аутентификатором. Затем введите текущие 6 цифр и нажмите <b>Подтвердить и открыть</b>.</p>${qr?`<img class="sun-dev-mfa-qr" src="${esc(qr)}" alt="QR для 2FA">`:''}${secret?`<p class="sun-dev-muted">Если QR не сканируется, добавьте ключ вручную:</p><div class="sun-dev-code">${esc(secret)}</div>`:''}<label style="display:block;margin-top:14px">6-значный код<input id="sunDevMfaCode" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000"></label><div class="actions"><button class="primary" id="sunDevMfaVerify" type="button">Подтвердить и открыть</button></div><div id="sunDevMfaError" class="sun-dev-muted" style="margin-top:8px"></div>`:`<p>Введите текущий 6-значный код из приложения-аутентификатора.</p><label>Код<input id="sunDevMfaCode" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000"></label><div class="actions"><button class="primary" id="sunDevMfaVerify" type="button">Подтвердить и открыть</button></div><div id="sunDevMfaError" class="sun-dev-muted" style="margin-top:8px"></div>`);
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('<svg')?'data:image/svg+xml;charset=utf-8,'+encodeURIComponent(rawQr):rawQr;
const secret=enrollment?.totp?.secret||'';
const intro=firstSetup?`<p>Отсканируйте QR-код приложением-аутентификатором, затем введите 6 цифр из этого приложения.</p><p class="sun-dev-muted">Закрыли окно случайно? Откройте его снова — настройку можно продолжить. После перезагрузки используйте новый QR-код, показанный здесь.</p>${qr?`<img class="sun-dev-mfa-qr" src="${esc(qr)}" alt="QR для 2FA">`:''}${secret?`<p class="sun-dev-muted">Или добавьте ключ вручную:</p><div class="sun-dev-code">${esc(secret)}</div>`:''}`:'<p>Введите текущий 6-значный код из приложения-аутентификатора. Подтверждённая защита уже подключена.</p>';
const ui=modal(firstSetup?'Подключение 2FA разработчика':'Подтверждение разработчика',`${intro}<label style="display:block;margin-top:14px">6-значный код<input id="sunDevMfaCode" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000"></label><div class="actions"><button class="primary" id="sunDevMfaVerify" type="button">Подтвердить и открыть</button></div><div id="sunDevMfaError" role="alert" class="sun-dev-muted" style="margin-top:8px"></div>`);
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=`<div class="sun-dev-grid"><div class="sun-dev-kpi"><small>Компании</small><b>${Number(d.companies||0)}</b></div><div class="sun-dev-kpi"><small>Аккаунты</small><b>${Number(d.accounts||0)}</b></div><div class="sun-dev-kpi"><small>Активные подписки</small><b>${Number(d.active_subscriptions||0)}</b></div><div class="sun-dev-kpi"><small>Ограниченные компании</small><b>${Number(d.restricted_companies||0)}</b></div><div class="sun-dev-kpi"><small>Ошибки за 24 ч.</small><b>${Number(d.errors_24h||0)}</b></div><div class="sun-dev-kpi"><small>Копии за 24 ч.</small><b>${Number(d.backups_24h||0)}</b></div><div class="sun-dev-kpi"><small>Участники компаний</small><b>${Number(d.memberships||0)}</b></div><div class="sun-dev-kpi"><small>Размер БД</small><b style="font-size:19px">${esc(d.database_size||'—')}</b></div></div><div class="sun-dev-section-grid" style="margin-top:12px"><div class="sun-dev-card"><h3>Платформа</h3><p><b>Версия приложения:</b> <span id="sunDevReleaseVersion">17.6.0</span></p><p><b>PostgreSQL:</b> ${esc(d.postgres_version||'—')}</p><p><b>Последнее изменение рабочей базы:</b> ${fmtDate(d.last_state_at)}</p></div><div class="sun-dev-card"><h3>Безопасность</h3><p><span class="sun-dev-pill ok">MFA AAL2</span> Кабинет разработчика защищён двухфакторной авторизацией.</p><p class="sun-dev-muted">Права разработчика проверяются сервером через sun_platform_admins.</p></div></div>`;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(_){} }

View File

@ -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',

View File

@ -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('<!doctype html><html><body><button id="entry">Developer</button></body></html>');
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:'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><rect width="20" height="20"/></svg>',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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function modal(title,html){document.getElementById('sunDevModalV22')?.remove();const m=document.createElement('div');m.id='sunDevModalV22';m.innerHTML='<h2>'+esc(title)+'</h2><button type="button" data-close>×</button>'+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/);
});

View File

@ -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}}
]