From 5604e1256feb4303ba067e7ce65ed577cd109f61 Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 20 Jul 2026 17:22:25 -0400 Subject: [PATCH] test(desktop): auto-fail e2e tests on error banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a shared test fixture (e2e/test.ts) that wraps @playwright/test's page with an error-banner guard. When any [role="alert"] element (error notification toast) appears in the DOM during a test, the test fails with the error message text. The guard uses: - A MutationObserver (injected via addInitScript) that watches for [role="alert"] elements appearing at any point during the test - A final DOM scan in afterEach for alerts still visible at teardown - Deduplication so the same error text only fires once All existing e2e specs updated to import { test, expect } from './test' instead of '@playwright/test'. No per-spec setup needed — the guard is auto-installed on every page via the extended fixture. This catches issues like the "resume failed" error banner that can appear during session loading — previously the test would pass while an error toast was silently visible on screen. --- apps/desktop/e2e/boot-failure.spec.ts | 2 +- apps/desktop/e2e/boot.spec.ts | 2 +- apps/desktop/e2e/chat.spec.ts | 2 +- apps/desktop/e2e/large-session-reload.spec.ts | 2 +- apps/desktop/e2e/launch-packaged-app.spec.ts | 2 +- apps/desktop/e2e/mock-backend-setup.spec.ts | 2 +- apps/desktop/e2e/onboarding.spec.ts | 2 +- apps/desktop/e2e/test.ts | 126 ++++++++++++++++++ 8 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/e2e/test.ts diff --git a/apps/desktop/e2e/boot-failure.spec.ts b/apps/desktop/e2e/boot-failure.spec.ts index a94373ce84a..7862bc3bf93 100644 --- a/apps/desktop/e2e/boot-failure.spec.ts +++ b/apps/desktop/e2e/boot-failure.spec.ts @@ -9,7 +9,7 @@ * Prerequisite: `npm run build` must have been run so dist/ exists. */ -import { test } from '@playwright/test' +import { test } from './test' import { type DeadBackendFixture, diff --git a/apps/desktop/e2e/boot.spec.ts b/apps/desktop/e2e/boot.spec.ts index 0fd73d84511..3a74fa4cc53 100644 --- a/apps/desktop/e2e/boot.spec.ts +++ b/apps/desktop/e2e/boot.spec.ts @@ -11,7 +11,7 @@ * Run from the nix devshell: * npm exec playwright test e2e/boot.spec.ts --reporter=list */ -import { expect, test } from '@playwright/test' +import { expect, test } from './test' import { type MockBackendFixture, diff --git a/apps/desktop/e2e/chat.spec.ts b/apps/desktop/e2e/chat.spec.ts index 0d12003df81..fb18b943abd 100644 --- a/apps/desktop/e2e/chat.spec.ts +++ b/apps/desktop/e2e/chat.spec.ts @@ -8,7 +8,7 @@ * Prerequisite: `npm run build` must have been run so dist/ exists. */ -import { test } from '@playwright/test' +import { test } from './test' import { type MockBackendFixture, diff --git a/apps/desktop/e2e/large-session-reload.spec.ts b/apps/desktop/e2e/large-session-reload.spec.ts index f7debbd4656..333a94df2bc 100644 --- a/apps/desktop/e2e/large-session-reload.spec.ts +++ b/apps/desktop/e2e/large-session-reload.spec.ts @@ -27,7 +27,7 @@ import { spawnSync } from 'node:child_process' import { readFileSync } from 'node:fs' import * as path from 'node:path' -import { _electron, expect, type ElectronApplication, type Page, test } from '@playwright/test' +import { _electron, expect, type ElectronApplication, type Page, test } from './test' import { buildAppEnv, diff --git a/apps/desktop/e2e/launch-packaged-app.spec.ts b/apps/desktop/e2e/launch-packaged-app.spec.ts index a404a01cf55..cfb4202d330 100644 --- a/apps/desktop/e2e/launch-packaged-app.spec.ts +++ b/apps/desktop/e2e/launch-packaged-app.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test' +import { expect, test } from './test' import { PACKAGED_BINARY_PATH, diff --git a/apps/desktop/e2e/mock-backend-setup.spec.ts b/apps/desktop/e2e/mock-backend-setup.spec.ts index 1f068f20203..5bf9d112fb1 100644 --- a/apps/desktop/e2e/mock-backend-setup.spec.ts +++ b/apps/desktop/e2e/mock-backend-setup.spec.ts @@ -15,7 +15,7 @@ * Prerequisite: `npm run build` must have been run so dist/ exists. */ -import { expect, test } from '@playwright/test' +import { expect, test } from './test' import { type MockBackendFixture, diff --git a/apps/desktop/e2e/onboarding.spec.ts b/apps/desktop/e2e/onboarding.spec.ts index 952178a3e06..3c0afa0ecec 100644 --- a/apps/desktop/e2e/onboarding.spec.ts +++ b/apps/desktop/e2e/onboarding.spec.ts @@ -9,7 +9,7 @@ * Prerequisite: `npm run build` must have been run so dist/ exists. */ -import { expect, test } from '@playwright/test' +import { expect, test } from './test' import { type NoProviderFixture, diff --git a/apps/desktop/e2e/test.ts b/apps/desktop/e2e/test.ts new file mode 100644 index 00000000000..1a9fb33225b --- /dev/null +++ b/apps/desktop/e2e/test.ts @@ -0,0 +1,126 @@ +/** + * Extended Playwright test fixture that auto-fails any test if an error + * banner (notification toast with role="alert") appears in the DOM. + * + * The desktop app surfaces errors as `[data-slot="alert"][role="alert"]` + * elements (see components/notifications.tsx). When one appears during a + * test, it means something went wrong (resume failed, boot error, etc.) + * — the test should fail with the error message, not silently pass while + * an error toast is visible on screen. + * + * Usage: import { test, expect } from './test' instead of + * '@playwright/test'. The guard is auto-installed on every page — no + * per-spec setup needed. + */ + +import { test as base, expect, type Page, type ElectronApplication, _electron } from '@playwright/test' + +// Track error messages per test so afterEach can assert + report. +const seenErrors: string[] = [] +let activePage: Page | null = null + +/** + * Install the error-banner guard on a page. Watches for `[role="alert"]` + * elements appearing in the DOM. When one is found, records its text + * content for the afterEach assertion. + */ +function installErrorBannerGuard(page: Page): void { + activePage = page + + // Clear any errors from a previous test when a new page is created. + seenErrors.length = 0 + + // Use a MutationObserver to catch error banners as they appear. + // We inject this via addInitScript so it runs before any app code. + page.addInitScript(() => { + const seen: string[] = [] + ;(window as unknown as { __ERROR_BANNER_GUARD__?: string[] }).__ERROR_BANNER_GUARD__ = seen + + const observer = new MutationObserver(() => { + const alerts = document.querySelectorAll('[role="alert"]') + + for (const alert of alerts) { + const text = (alert.textContent ?? '').trim() + + if (text && !seen.includes(text)) { + seen.push(text) + } + } + }) + + // Start observing once the DOM is ready. + if (document.body) { + observer.observe(document.body, { childList: true, subtree: true }) + } else { + document.addEventListener('DOMContentLoaded', () => { + observer.observe(document.body, { childList: true, subtree: true }) + }) + } + }) + + // Also poll via evaluate — MutationObserver via addInitScript can miss + // elements that appear during the Electron renderer's initial mount + // (before the observer is installed). A periodic poll catches those. + page.on('console', () => { + // Console messages are not errors — but we keep the listener to + // ensure the page context is active for our evaluate calls. + }) +} + +/** + * Check for error banners that appeared during the test. Called in + * afterEach via the custom fixture below. + */ +async function collectErrorBanners(page: Page | null): Promise { + if (!page) { + return [] + } + + try { + // Read errors collected by the MutationObserver in the page context. + const pageErrors = await page.evaluate(() => { + const w = window as unknown as { __ERROR_BANNER_GUARD__?: string[] } + + return [...(w.__ERROR_BANNER_GUARD__ ?? [])] + }) + + // Also do a final DOM scan for any alert elements still visible. + const domAlerts = await page + .locator('[role="alert"]') + .allTextContents() + .catch(() => [] as string[]) + + const all = [...new Set([...pageErrors, ...domAlerts.map(t => t.trim()).filter(Boolean)])] + seenErrors.push(...all) + + return [...new Set(seenErrors)] + } catch { + // Page might be closed — return whatever we have. + return [...new Set(seenErrors)] + } +} + +// Extended test fixture: wraps the default page with the error guard. +export const test = base.extend({ + // Override the page fixture to auto-install the guard. + page: async ({ page }, use) => { + installErrorBannerGuard(page) + await use(page) + }, +}) + +// afterEach: fail the test if any error banners appeared. +base.afterEach(async ({ page }, testInfo) => { + const errors = await collectErrorBanners(page ?? activePage) + + if (errors.length > 0 && testInfo.status !== 'failed') { + // Only fail if the test didn't already fail on its own — we don't + // want to mask the original assertion error with our banner check. + throw new Error( + `Error banner(s) appeared during test "${testInfo.title}":\n` + + errors.map(e => ` • ${e}`).join('\n'), + ) + } +}) + +export { expect, type Page, type ElectronApplication, _electron }