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"
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
as="h3"
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 h-6 items-center">
<input
data-testid="privacy-agree-checkbox"
id="privacy-agree"
type="checkbox"
checked={agreed}
@ -129,6 +130,7 @@ export function PrivacyModal({ onAccept }: PrivacyModalProps) {
<div className="flex justify-end">
<Button
data-testid="privacy-continue-button"
type="button"
disabled={!agreed}
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"
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 */}
<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">
@ -660,6 +660,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
Reset
</Button>
<Button
data-testid="settings-save-button"
type="button"
className={`${btnPrimary} px-4 py-2`}
disabled={!canSubmit}

View file

@ -197,7 +197,7 @@ export function DexieMigrationModal() {
leaveFrom="opacity-100 scale-100"
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">
{title}
</DialogTitle>
@ -222,6 +222,7 @@ export function DexieMigrationModal() {
<div className="flex justify-end gap-3 mt-6">
<Button
data-testid="migration-skip-button"
onClick={handleSkip}
disabled={isUploading}
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 targetLink = page.locator(`a[href$="/pdf/${expectedId}"]`).first();
await expect(targetLink).toBeVisible({ timeout: 15000 });
await dismissOnboardingModals(page);
await targetLink.click();
await page.waitForSelector('.react-pdf__Document', { timeout: 15000 });
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();
for (let attempt = 0; attempt < 3; attempt++) {
await dismissSettingsModalIfVisible(page);
await dismissOnboardingModals(page);
try {
await targetLink.click({ timeout: 10000 });
break;
} catch (error) {
if (attempt === 2) throw error;
const message = String(error);
if (!message.includes('intercepts pointer events')) throw error;
await page.waitForTimeout(200);
}
}
@ -110,23 +109,58 @@ export async function uploadAndDisplay(page: Page, fileName: string) {
}
}
async function dismissSettingsModalIfVisible(page: Page): Promise<void> {
const settingsDialog = page.getByRole('dialog', { name: 'Settings' });
const visible = await settingsDialog.isVisible().catch(() => false);
if (!visible) return;
async function dismissOnboardingModals(page: Page): Promise<void> {
const privacyDialog = page.getByTestId('privacy-modal');
const migrationDialog = page.getByTestId('migration-modal');
const settingsDialog = page.getByTestId('settings-modal');
const saveBtn = settingsDialog.getByRole('button', { name: 'Save' });
const saveVisible = await saveBtn.isVisible().catch(() => false);
if (saveVisible) {
await expect(saveBtn).toBeEnabled({ timeout: 10000 }).catch(() => { });
await saveBtn.click({ force: true }).catch(async () => {
await page.keyboard.press('Escape').catch(() => { });
});
} else {
await page.keyboard.press('Escape').catch(() => { });
const maxSteps = 12;
const settleChecks = 3;
let settledWithoutDialog = 0;
for (let step = 0; step < maxSteps; step += 1) {
if (await privacyDialog.isVisible().catch(() => false)) {
const privacyAgree = page.getByTestId('privacy-agree-checkbox');
if (await privacyAgree.isVisible().catch(() => false)) {
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 })
.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.
const cookieAcceptBtn = page.getByRole('button', { name: 'Accept All' });
if (await cookieAcceptBtn.isVisible().catch(() => false)) {
await cookieAcceptBtn.click();
}
// Settings modal should appear on first visit. In non-auth mode there is no
// privacy modal, so open Settings explicitly if onboarding did not auto-open.
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 });
// Close first-run dialogs (when present). These do not always appear.
await dismissOnboardingModals(page);
}
/**