test(ui): stabilize onboarding/modal dismissal in e2e helpers

Refactor test helpers to reliably dismiss onboarding and migration modals during
UI flows. Replace ad-hoc Settings-only dismissal with a unified dismissOnboardingModals
routine that targets privacy, migration, and settings dialogs via testids,
interacts with visible controls (checkbox, continue/skip/save buttons) and waits
for dialogs to hide. Increase resilience by repeating checks, adding short
settle delays, and tightening timeouts.

Also add data-testid attributes to PrivacyModal, SettingsModal and
DexieMigrationModal to make the selectors deterministic and reduce flakiness in
export/upload tests.

No production behavior changes.
This commit is contained in:
Richard R 2026-04-06 11:16:54 -06:00
parent dbd202569f
commit 7d9d8de967
4 changed files with 61 additions and 67 deletions

View file

@ -96,7 +96,7 @@ export function PrivacyModal({ onAccept }: PrivacyModalProps) {
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all"> <DialogPanel data-testid="privacy-modal" className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle <DialogTitle
as="h3" as="h3"
className="text-lg font-semibold leading-6 text-foreground" className="text-lg font-semibold leading-6 text-foreground"
@ -110,6 +110,7 @@ export function PrivacyModal({ onAccept }: PrivacyModalProps) {
<div className="flex items-start gap-3 rounded-lg border border-offbase p-3 bg-offbase/20"> <div className="flex items-start gap-3 rounded-lg border border-offbase p-3 bg-offbase/20">
<div className="flex h-6 items-center"> <div className="flex h-6 items-center">
<input <input
data-testid="privacy-agree-checkbox"
id="privacy-agree" id="privacy-agree"
type="checkbox" type="checkbox"
checked={agreed} checked={agreed}
@ -129,6 +130,7 @@ export function PrivacyModal({ onAccept }: PrivacyModalProps) {
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
data-testid="privacy-continue-button"
type="button" type="button"
disabled={!agreed} disabled={!agreed}
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm

View file

@ -405,7 +405,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<DialogPanel className="w-full max-w-3xl transform rounded-2xl bg-base text-left align-middle shadow-xl transition-all overflow-hidden"> <DialogPanel data-testid="settings-modal" className="w-full max-w-3xl transform rounded-2xl bg-base text-left align-middle shadow-xl transition-all overflow-hidden">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-offbase"> <div className="flex items-center justify-between px-6 py-4 border-b border-offbase">
<DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground"> <DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground">
@ -660,6 +660,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
Reset Reset
</Button> </Button>
<Button <Button
data-testid="settings-save-button"
type="button" type="button"
className={`${btnPrimary} px-4 py-2`} className={`${btnPrimary} px-4 py-2`}
disabled={!canSubmit} disabled={!canSubmit}

View file

@ -197,7 +197,7 @@ export function DexieMigrationModal() {
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all"> <DialogPanel data-testid="migration-modal" className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground mb-4"> <DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground mb-4">
{title} {title}
</DialogTitle> </DialogTitle>
@ -222,6 +222,7 @@ export function DexieMigrationModal() {
<div className="flex justify-end gap-3 mt-6"> <div className="flex justify-end gap-3 mt-6">
<Button <Button
data-testid="migration-skip-button"
onClick={handleSkip} onClick={handleSkip}
disabled={isUploading} disabled={isUploading}
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm

View file

@ -82,6 +82,7 @@ export async function uploadAndDisplay(page: Page, fileName: string) {
const expectedId = sha256HexOfFile(fixturePath(fileName)); const expectedId = sha256HexOfFile(fixturePath(fileName));
const targetLink = page.locator(`a[href$="/pdf/${expectedId}"]`).first(); const targetLink = page.locator(`a[href$="/pdf/${expectedId}"]`).first();
await expect(targetLink).toBeVisible({ timeout: 15000 }); await expect(targetLink).toBeVisible({ timeout: 15000 });
await dismissOnboardingModals(page);
await targetLink.click(); await targetLink.click();
await page.waitForSelector('.react-pdf__Document', { timeout: 15000 }); await page.waitForSelector('.react-pdf__Document', { timeout: 15000 });
return; return;
@ -89,14 +90,12 @@ export async function uploadAndDisplay(page: Page, fileName: string) {
const targetLink = page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).first(); const targetLink = page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).first();
for (let attempt = 0; attempt < 3; attempt++) { for (let attempt = 0; attempt < 3; attempt++) {
await dismissSettingsModalIfVisible(page); await dismissOnboardingModals(page);
try { try {
await targetLink.click({ timeout: 10000 }); await targetLink.click({ timeout: 10000 });
break; break;
} catch (error) { } catch (error) {
if (attempt === 2) throw error; if (attempt === 2) throw error;
const message = String(error);
if (!message.includes('intercepts pointer events')) throw error;
await page.waitForTimeout(200); await page.waitForTimeout(200);
} }
} }
@ -110,23 +109,58 @@ export async function uploadAndDisplay(page: Page, fileName: string) {
} }
} }
async function dismissSettingsModalIfVisible(page: Page): Promise<void> { async function dismissOnboardingModals(page: Page): Promise<void> {
const settingsDialog = page.getByRole('dialog', { name: 'Settings' }); const privacyDialog = page.getByTestId('privacy-modal');
const visible = await settingsDialog.isVisible().catch(() => false); const migrationDialog = page.getByTestId('migration-modal');
if (!visible) return; const settingsDialog = page.getByTestId('settings-modal');
const saveBtn = settingsDialog.getByRole('button', { name: 'Save' }); const maxSteps = 12;
const saveVisible = await saveBtn.isVisible().catch(() => false); const settleChecks = 3;
if (saveVisible) { let settledWithoutDialog = 0;
await expect(saveBtn).toBeEnabled({ timeout: 10000 }).catch(() => { });
await saveBtn.click({ force: true }).catch(async () => { for (let step = 0; step < maxSteps; step += 1) {
await page.keyboard.press('Escape').catch(() => { }); if (await privacyDialog.isVisible().catch(() => false)) {
}); const privacyAgree = page.getByTestId('privacy-agree-checkbox');
} else { if (await privacyAgree.isVisible().catch(() => false)) {
await page.keyboard.press('Escape').catch(() => { }); if (!(await privacyAgree.isChecked())) {
await privacyAgree.check();
}
}
const continueBtn = page.getByTestId('privacy-continue-button');
await expect(continueBtn).toBeEnabled({ timeout: 10000 });
await continueBtn.click();
await privacyDialog.waitFor({ state: 'hidden', timeout: 15000 });
await page.waitForTimeout(100);
settledWithoutDialog = 0;
continue;
}
if (await migrationDialog.isVisible().catch(() => false)) {
const skipBtn = page.getByTestId('migration-skip-button');
await expect(skipBtn).toBeEnabled({ timeout: 10000 });
await skipBtn.click();
await migrationDialog.waitFor({ state: 'hidden', timeout: 15000 });
await page.waitForTimeout(100);
settledWithoutDialog = 0;
continue;
}
if (await settingsDialog.isVisible().catch(() => false)) {
const saveBtn = page.getByTestId('settings-save-button');
await expect(saveBtn).toBeEnabled({ timeout: 15000 });
await saveBtn.click();
await settingsDialog.waitFor({ state: 'hidden', timeout: 15000 });
await page.waitForTimeout(100);
settledWithoutDialog = 0;
continue;
}
settledWithoutDialog += 1;
if (settledWithoutDialog >= settleChecks) {
return;
}
await page.waitForTimeout(150);
} }
await settingsDialog.waitFor({ state: 'hidden', timeout: 15000 }).catch(() => { });
} }
/** /**
@ -237,58 +271,14 @@ export async function setupTest(page: Page, testInfo?: TestInfo) {
.waitForSelector('.fixed.inset-0.bg-base.z-50', { state: 'detached', timeout: 15_000 }) .waitForSelector('.fixed.inset-0.bg-base.z-50', { state: 'detached', timeout: 15_000 })
.catch(() => { }); .catch(() => { });
// Privacy modal should come first in onboarding.
// Be tolerant if it's already accepted (e.g., reused context).
const privacyBtn = page.getByRole('button', { name: /Continue|I Understand/i });
try {
await expect(privacyBtn).toBeVisible({ timeout: 5000 });
const privacyAgree = page.locator('#privacy-agree');
if ((await privacyAgree.count()) > 0) {
await privacyAgree.check();
}
await expect(privacyBtn).toBeEnabled({ timeout: 5000 });
await privacyBtn.click();
// HeadlessUI keeps dialogs in the DOM during leave transitions; "hidden" is enough
// (we mainly need to ensure it no longer blocks pointer events).
await page.getByRole('dialog', { name: /privacy/i }).waitFor({ state: 'hidden', timeout: 15000 });
} catch {
// ignore
}
// Fallback: if the banner still appears, dismiss it before continuing. // Fallback: if the banner still appears, dismiss it before continuing.
const cookieAcceptBtn = page.getByRole('button', { name: 'Accept All' }); const cookieAcceptBtn = page.getByRole('button', { name: 'Accept All' });
if (await cookieAcceptBtn.isVisible().catch(() => false)) { if (await cookieAcceptBtn.isVisible().catch(() => false)) {
await cookieAcceptBtn.click(); await cookieAcceptBtn.click();
} }
// Settings modal should appear on first visit. In non-auth mode there is no // Close first-run dialogs (when present). These do not always appear.
// privacy modal, so open Settings explicitly if onboarding did not auto-open. await dismissOnboardingModals(page);
const settingsDialog = page.getByRole('dialog', { name: 'Settings' });
const saveBtn = settingsDialog.getByRole('button', { name: 'Save' });
// On some local runs, Settings opens automatically but the Save button is not yet
// visible during enter transition. Prefer waiting for it before trying to click.
await saveBtn.waitFor({ state: 'visible', timeout: 2500 }).catch(async () => {
const settingsBtn = page.getByRole('button', { name: 'Settings' });
if (await settingsBtn.isVisible().catch(() => false)) {
// Force avoids occasional pointer interception by in-flight dialog overlays.
await settingsBtn.click({ force: true });
}
});
await expect(saveBtn).toBeVisible({ timeout: 10000 });
// SettingsModal can briefly disable Save while it mirrors a custom model into the input field.
await expect(saveBtn).toBeEnabled({ timeout: 15000 });
// If running in CI, select the "Custom OpenAI-Like" model and "Deepinfra" provider
if (process.env.CI) {
await page.getByRole('button', { name: 'Custom OpenAI-Like' }).click();
await page.getByText('Deepinfra').click();
}
// Click the "done" button to dismiss the welcome message
await saveBtn.click();
await settingsDialog.waitFor({ state: 'hidden', timeout: 15000 });
} }
/** /**