From 3681b23d30fbe591cd11ccd0b0ee189dcd48210d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 21:36:20 +0200 Subject: [PATCH] test(e2e): mount every overview section before asserting none paints The AI-optional overview and account journeys asserted zero error cards, Coach hand-offs and briefings without scrolling the deferred sections into view, so the sections below the first three never mounted and the zero counts held whatever they would paint. Both journeys now reveal every section and wait for the reads those sections fire to land; waiting on networkidle again resolves at once, because the page reached that state before. --- e2e/ai-optional-account.spec.ts | 17 ++++++++++----- e2e/ai-optional-overview.spec.ts | 13 +++++++++++ e2e/utils/deferred-sections.ts | 37 ++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/e2e/ai-optional-account.spec.ts b/e2e/ai-optional-account.spec.ts index 3e7f138fe..6a27a80cd 100644 --- a/e2e/ai-optional-account.spec.ts +++ b/e2e/ai-optional-account.spec.ts @@ -25,6 +25,7 @@ import { seedStoredBriefing, } from "./setup/ai-optional-fixture"; import { expect, test } from "./setup/test"; +import { revealDeferredSectionsSettled } from "./utils/deferred-sections"; test.use({ storageState: AI_OPTIONAL_STORAGE_STATE_PATH }); test.describe.configure({ mode: "serial" }); @@ -36,12 +37,18 @@ test.afterAll(async () => { await resetAiOptionalAccount(); }); +/** + * The overview with every section mounted. Sections past the first three + * render as an empty sentinel until they near the viewport, so a count of + * zero taken before they mount would pass whatever they paint. + */ async function openOverview(page: Page): Promise { await page.goto("/insights"); await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible({ timeout: 20_000, }); await page.waitForLoadState("networkidle"); + await revealDeferredSectionsSettled(page); } test("J5: withdrawing AI consent deletes the notes written under it", async ({ @@ -80,12 +87,12 @@ test("J4: the Coach memory stays readable and deletable with the Coach hidden", const title = `Stored conversation ${Date.now()}`; await seedHiddenCoachWithConversation(title); - // Hide Coach takes the Coach page and its launcher away... - await page.goto("/insights"); - await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible({ - timeout: 20_000, - }); + // Hide Coach takes the Coach page, its launcher and every per-card + // hand-off away... + await openOverview(page); await expect(page.locator('[data-slot="coach-fab"]')).toHaveCount(0); + await expect(page.locator('[data-slot="ask-coach-action"]')).toHaveCount(0); + await expect(page.locator('[data-slot="ask-coach-icon"]')).toHaveCount(0); // ...and Settings, AI lists what the Coach stored. await page.goto("/settings/ai"); diff --git a/e2e/ai-optional-overview.spec.ts b/e2e/ai-optional-overview.spec.ts index dac4533bd..ba0158dd0 100644 --- a/e2e/ai-optional-overview.spec.ts +++ b/e2e/ai-optional-overview.spec.ts @@ -21,9 +21,19 @@ import type { Page, Request, Response } from "@playwright/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { aiBlockUnavailable, serveAiBlock } from "./setup/ai-capabilities"; import { expect, test } from "./setup/test"; +import { revealDeferredSectionsSettled } from "./utils/deferred-sections"; test.use({ storageState: STORAGE_STATE_PATH }); +/** + * Mount every overview section, then let their reads land. Sections past the + * first three render as an empty sentinel until they near the viewport, so a + * count of zero taken before this would pass whatever those sections paint. + */ +async function mountWholeOverview(page: Page): Promise { + await revealDeferredSectionsSettled(page); +} + /** Routes that call a model or serve model-written text. */ const AI_ROUTE = new RegExp( [ @@ -89,6 +99,7 @@ test.describe("J1: AI switched off by the operator", () => { ); // Let every section's reads land before judging what painted. await page.waitForLoadState("networkidle"); + await mountWholeOverview(page); // A read refused for want of AI paints one of these on the overview: // the section boundary, the error card, or the dense error row. @@ -137,6 +148,7 @@ test.describe("J3: no AI provider", () => { timeout: 20_000, }); await page.waitForLoadState("networkidle"); + await mountWholeOverview(page); await expect(page.locator('[data-slot="coach-fab"]')).toHaveCount(0); await expect(page.locator('[data-slot="daily-briefing"]')).toHaveCount(0); // The per-card Coach hand-offs paint only while the Coach is available. @@ -163,6 +175,7 @@ test.describe("J3: no AI provider", () => { { timeout: 20_000 }, ); await page.waitForLoadState("networkidle"); + await mountWholeOverview(page); await expect(page.locator('[data-slot="ai-setup-hint"]')).toHaveCount(0); expect(network.aiRequests).toEqual([]); diff --git a/e2e/utils/deferred-sections.ts b/e2e/utils/deferred-sections.ts index 0ca4748ea..247ab9d80 100644 --- a/e2e/utils/deferred-sections.ts +++ b/e2e/utils/deferred-sections.ts @@ -36,3 +36,40 @@ export async function revealDeferredSections(page: Page): Promise { .toBe(0); await page.evaluate(() => document.querySelector("main")?.scrollTo(0, 0)); } + +/** + * `revealDeferredSections`, then wait until the reads the newly mounted + * sections fired have landed: no request in flight for 750 ms. A + * `waitForLoadState("networkidle")` cannot do this: the page reached that + * state once already, so it resolves at once, and a count of zero taken then + * passes before a deferred section has painted anything. + */ +export async function revealDeferredSectionsSettled(page: Page): Promise { + let inFlight = 0; + const started = () => { + inFlight += 1; + }; + const ended = () => { + inFlight -= 1; + }; + page.on("request", started); + page.on("requestfinished", ended); + page.on("requestfailed", ended); + try { + await revealDeferredSections(page); + let quietSince = Date.now(); + await expect + .poll( + () => { + if (inFlight > 0) quietSince = Date.now(); + return Date.now() - quietSince >= 750; + }, + { timeout: 20_000, intervals: [100] }, + ) + .toBe(true); + } finally { + page.off("request", started); + page.off("requestfinished", ended); + page.off("requestfailed", ended); + } +}