From e113684ddb95276ccb6cf58b96898d292e51455a Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 18 Jul 2026 11:22:38 +0800 Subject: [PATCH] test(docs): make search and nav e2e tests hydration-aware (#551) The docs e2e suite clicked navbar and sidebar controls before Vue hydrated the multi-locale bundle, so the clicks were swallowed. That raced the Pagefind search open (filed as #551), the appearance toggle, and the homepage and sidebar navigation tests. The old search tests also matched an input placeholder the config overrides, so they failed against a working build. Add a waitForHydration helper (gates on #app.__vue_app__, set inside Vue's app.mount()) and an openDocsSearch helper, and route the affected tests through them. Search itself was never broken; this change is test-only. Docs e2e suite is green (43/43). Closes #551 --- tests/e2e-docs/helpers.ts | 45 +++++++++++++++++++++++++ tests/e2e-docs/homepage.spec.ts | 4 +++ tests/e2e-docs/i18n.spec.ts | 6 ++-- tests/e2e-docs/search-and-theme.spec.ts | 22 +++++++----- tests/e2e-docs/sidebar.spec.ts | 7 ++++ 5 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 tests/e2e-docs/helpers.ts diff --git a/tests/e2e-docs/helpers.ts b/tests/e2e-docs/helpers.ts new file mode 100644 index 00000000..554ddd43 --- /dev/null +++ b/tests/e2e-docs/helpers.ts @@ -0,0 +1,45 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +/** + * Wait for VitePress to finish the initial client hydration. + * + * The docs navbar (the Pagefind search box, the appearance toggle) is + * server-rendered, so it's present in the DOM the instant the page loads, but + * its Vue click handlers aren't wired until the app hydrates. A click that + * lands before then is silently swallowed. That's the flaky "search won't + * open" race behind issue #551: the bundle for 21 locales takes a few hundred + * ms to hydrate, and a fast (or automated) click inside that window does + * nothing. Playwright's actionability checks don't cover framework hydration, + * so tests have to wait for it explicitly. + * + * Vue assigns `__vue_app__` to the mount container inside `app.mount()`, which + * for SSR is the same call that hydrates the initial tree. Its presence on + * `#app` is therefore a deterministic, side-effect-free signal that handlers + * are attached. + */ +export async function waitForHydration(page: Page): Promise { + await page.waitForFunction( + () => { + const app = document.getElementById("app"); + return app !== null && "__vue_app__" in app; + }, + null, + { timeout: 15_000 }, + ); +} + +/** + * Open the Pagefind search dialog and return its input locator. + * + * Waits for hydration first (so the click registers), then asserts the dialog + * input is visible. The selector is placeholder-agnostic on purpose: the docs + * config overrides the input placeholder, so keying off a hard-coded string + * (as the old tests did) breaks whenever that copy changes. + */ +export async function openDocsSearch(page: Page): Promise { + await waitForHydration(page); + await page.locator(".nav-search-btn-wait").first().click(); + const input = page.locator("[command-dialog-wrapper] input").first(); + await expect(input).toBeVisible({ timeout: 5_000 }); + return input; +} diff --git a/tests/e2e-docs/homepage.spec.ts b/tests/e2e-docs/homepage.spec.ts index 01d8f2b6..5a18503a 100644 --- a/tests/e2e-docs/homepage.spec.ts +++ b/tests/e2e-docs/homepage.spec.ts @@ -1,8 +1,12 @@ import { expect, test } from "@playwright/test"; +import { waitForHydration } from "./helpers"; test.describe("docs homepage (Two Doors)", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); + // Some tests click nav/chip links, which route client-side; wait for Vue to + // hydrate first so the click isn't swallowed (the #551 pre-hydration race). + await waitForHydration(page); }); test("title contains SnapOtter", async ({ page }) => { diff --git a/tests/e2e-docs/i18n.spec.ts b/tests/e2e-docs/i18n.spec.ts index 91dd554a..bf1bb427 100644 --- a/tests/e2e-docs/i18n.spec.ts +++ b/tests/e2e-docs/i18n.spec.ts @@ -1,5 +1,6 @@ // tests/e2e-docs/i18n.spec.ts import { expect, test } from "@playwright/test"; +import { openDocsSearch } from "./helpers"; test.describe("docs i18n (English + seeded German locale)", () => { test("English home still renders at root", async ({ page }) => { @@ -50,9 +51,8 @@ test.describe("docs i18n (English + seeded German locale)", () => { test("pagefind returns results within the German locale", async ({ page }) => { await page.goto("/de/guide/getting-started"); - await page.locator(".blog-search").first().click(); - const input = page.locator("input[placeholder]").first(); + const input = await openDocsSearch(page); await input.fill("docker"); - await expect(page.locator('[role="option"]').first()).toBeVisible({ timeout: 10000 }); + await expect(page.locator('[role="option"]').first()).toBeVisible({ timeout: 10_000 }); }); }); diff --git a/tests/e2e-docs/search-and-theme.spec.ts b/tests/e2e-docs/search-and-theme.spec.ts index 4af44e7b..a9181740 100644 --- a/tests/e2e-docs/search-and-theme.spec.ts +++ b/tests/e2e-docs/search-and-theme.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { openDocsSearch, waitForHydration } from "./helpers"; test.describe("docs search (Pagefind)", () => { test("search button is visible", async ({ page }) => { @@ -8,16 +9,18 @@ test.describe("docs search (Pagefind)", () => { test("clicking search opens the dialog with an input", async ({ page }) => { await page.goto("/"); - await page.locator(".blog-search").first().click(); - await expect(page.locator('input[placeholder="Search Docs"]')).toBeVisible(); + const input = await openDocsSearch(page); + // It's the real search combobox with a (config-driven) placeholder. Don't + // assert the exact placeholder copy, just that one is present. + await expect(input).toHaveAttribute("role", "combobox"); + await expect(input).toHaveAttribute("placeholder", /\S/); }); test("typing a query shows results", async ({ page }) => { await page.goto("/"); - await page.locator(".blog-search").first().click(); - const input = page.locator('input[placeholder="Search Docs"]'); + const input = await openDocsSearch(page); await input.fill("docker"); - await expect(page.locator('[role="option"]').first()).toBeVisible({ timeout: 10000 }); + await expect(page.locator('[role="option"]').first()).toBeVisible({ timeout: 10_000 }); }); }); @@ -32,13 +35,16 @@ test.describe("Theme Toggle", () => { test("clicking theme toggle changes appearance", async ({ page }) => { await page.goto("/guide/getting-started"); + // The appearance switch is a Vue handler, so wait for hydration before the + // single click (otherwise the click is swallowed and the class never flips). + await waitForHydration(page); + const html = page.locator("html"); - const initialClass = await html.getAttribute("class"); - const wasDark = initialClass?.includes("dark") ?? false; + const wasDark = ((await html.getAttribute("class")) ?? "").includes("dark"); // Click the visible toggle inside our custom nav area (the hidden default // VPNavBarAppearance is first in DOM order, so a bare querySelector would - // hit it instead). Playwright's click auto-waits for hydration. + // hit it instead). await page.locator('.nav-bar-right button[role="switch"]').click(); // Assert the dark class actually toggled diff --git a/tests/e2e-docs/sidebar.spec.ts b/tests/e2e-docs/sidebar.spec.ts index 0b07482b..5c7fbc66 100644 --- a/tests/e2e-docs/sidebar.spec.ts +++ b/tests/e2e-docs/sidebar.spec.ts @@ -1,8 +1,12 @@ import { expect, test } from "@playwright/test"; +import { waitForHydration } from "./helpers"; test.describe("Docs Sidebar - Guide Section", () => { test.beforeEach(async ({ page }) => { await page.goto("/guide/getting-started"); + // Sidebar links route client-side; wait for hydration so the click lands + // (otherwise it's swallowed pre-hydration, the #551 race). + await waitForHydration(page); }); test("sidebar renders all guide links", async ({ page }) => { @@ -53,6 +57,7 @@ test.describe("Docs Sidebar - Guide Section", () => { test.describe("Docs Sidebar - API Reference Section", () => { test("clicking REST API navigates correctly", async ({ page }) => { await page.goto("/guide/getting-started"); + await waitForHydration(page); await page.locator(".VPSidebar a, aside a").filter({ hasText: "REST API" }).click(); await expect(page).toHaveURL(/\/api\/rest/); await expect(page.getByText("REST API Reference")).toBeVisible(); @@ -60,12 +65,14 @@ test.describe("Docs Sidebar - API Reference Section", () => { test("clicking Image engine navigates correctly", async ({ page }) => { await page.goto("/api/rest"); + await waitForHydration(page); await page.locator(".VPSidebar a, aside a").filter({ hasText: "Image engine" }).click(); await expect(page).toHaveURL(/\/api\/image-engine/); }); test("clicking AI engine navigates correctly", async ({ page }) => { await page.goto("/api/rest"); + await waitForHydration(page); await page.locator(".VPSidebar a, aside a").filter({ hasText: "AI engine" }).click(); await expect(page).toHaveURL(/\/api\/ai/); });