Files
template-app/e2e/hydration-links.spec.ts
2026-11-14 09:56:45 +00:00

182 lines
8.1 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// A slow current module must not replace the server-rendered link during its
// first gesture. Real app/Postgres; fake managed platform, not Cloud acceptance.
import { writeFile } from "node:fs/promises"
import { expect, test } from "@playwright/test"
import { call, makeOrganization, ready, signIn, unique } from "./support/app"
for (const scope of ["workspace", "workspace-row", "project"] as const) {
test(`${scope} server link survives hydration while its view code is delayed`, async ({ page }) => {
await signIn(page, `${unique("owner")}@example.test`)
const slugs = await makeOrganization(page)
const notes = `/${slugs.org}/${slugs.workspace}/${slugs.project}/notes`
const workspace = `/${slugs.org}/${slugs.workspace}/modules/notes`
let path = `${workspace}?q=no+matching+project`
let name = "Clear filter"
if (scope === "workspace-row") {
path = workspace
name = "Review"
}
if (scope === "project") {
const { noteId } = await call<{ noteId: string }>(page, "notes.note.create", {
...slugs,
title: "Hydration note",
body: "A server-rendered detail.",
})
path = `${notes}/${noteId}`
name = "All notes"
}
let release = (): void => {}
const code = new Promise<void>((resolve) => {
release = resolve
})
let requested = false
const chunk = scope === "project" ? "**/assets/pages-*.js" : "**/assets/workspace-page-*.js"
await page.route(chunk, async (route) => {
requested = true
await code
await route.continue()
})
try {
await page.goto(path, { waitUntil: "commit" })
const link = page.getByRole("link", { name, exact: true })
await expect(link).toBeVisible()
await expect.poll(() => requested).toBe(true)
const original = await page.evaluateHandle(
(label) => Array.from(document.querySelectorAll("a[href]")).find((node) => node.textContent === label) ?? null,
name,
)
expect(await original.evaluate((node) => Boolean(node?.isConnected && node.getClientRects().length > 0))).toBe(
true,
)
const element = original.asElement()
if (!element) throw new Error("the server did not retain its link")
const box = await element.boundingBox()
if (!box) throw new Error("the server link has no hit target")
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
// Drive a real browser paint while the module response remains blocked.
// The current server node must remain a usable hit target, not fallback.
await page.evaluate(
() => new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))),
)
expect(await original.evaluate((node) => Boolean(node?.isConnected && node.getClientRects().length > 0))).toBe(
true,
)
// Nothing has hydrated while the page's code is held, so the document does not claim it is interactive.
expect(await page.locator("html").getAttribute("data-hydrated")).toBeNull()
release()
// A live page keeps its stream open, so the network never goes idle: wait for hydration itself.
await ready(page)
expect(await original.evaluate((node) => Boolean(node?.isConnected && node.getClientRects().length > 0))).toBe(
true,
)
// Hydration and late provider styles must preserve the original hit target.
// Do not move the pointer again: that would hide the lost-click defect.
expect(await element.boundingBox()).toEqual(box)
expect(
await original.evaluate((node, point) => node?.contains(document.elementFromPoint(point.x, point.y)) ?? false, {
x: box.x + box.width / 2,
y: box.y + box.height / 2,
}),
).toBe(true)
await page.mouse.up()
await expect(page).toHaveURL(scope === "workspace" ? workspace : notes)
await expect(page.getByRole("heading", { level: 1 })).toBeVisible()
} finally {
release()
}
})
}
test("JS-only selection stays disabled until the matched workspace view is hydrated", async ({ page }) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org, workspace } = await makeOrganization(page)
let release = (): void => {}
const code = new Promise<void>((resolve) => {
release = resolve
})
let requested = false
await page.route("**/assets/workspace-page-*.js", async (route) => {
requested = true
await code
await route.continue()
})
try {
await page.goto(`/${org}/${workspace}/modules/notes`, { waitUntil: "commit" })
await expect.poll(() => requested).toBe(true)
const status = page.locator("[data-slot=resource-count]")
await expect(status).toHaveText("0 projects selected")
const checkbox = page.getByRole("checkbox", { name: "Review", exact: true })
await expect(checkbox).toBeDisabled()
const select = page.locator("label").filter({ has: checkbox }).click()
release()
await select
await expect(status).toHaveText("1 project selected")
} finally {
release()
}
})
test("a failed initial module has a recovery page and can reload after the network recovers", async ({
page,
}, info) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org, workspace } = await makeOrganization(page)
const errors: string[] = []
const consoleMessages: string[] = []
const responses: { path: string; status: number }[] = []
let offline = true
page.on("pageerror", (error) => errors.push(error.message))
page.on("console", (message) => {
if (message.type() === "error" || message.type() === "warning") consoleMessages.push(message.text())
})
page.on("response", (response) => {
responses.push({ path: new URL(response.url()).pathname, status: response.status() })
})
await page.route("**/assets/workspace-page-*.js", (route) =>
offline
? route.fulfill({ status: 503, contentType: "text/javascript", body: "// module temporarily unavailable" })
: route.continue(),
)
await page.goto(`/${org}/${workspace}/modules/notes`)
await expect(page.getByRole("heading", { level: 1, name: "Something went wrong" })).toBeVisible()
await expect(page.getByRole("alert")).toContainText("Try again")
await expect(page.getByRole("link", { name: "Back to the start" })).toHaveAttribute("href", "/")
await page.screenshot({ path: info.outputPath("module-download-recovery.png") })
expect(responses.some((response) => response.path.includes("workspace-page-") && response.status === 503)).toBe(true)
// React reports its fallback from concurrent to synchronous rendering when
// this deliberately broken import reaches the boundary. Keep that diagnostic
// in the evidence, while rejecting unrelated errors and any after recovery.
expect(
errors.filter(
(message) =>
message !==
"There was an error during concurrent rendering but React was able to recover by instead synchronously rendering the entire root." &&
!message.startsWith("Minified React error #520;"),
),
).toEqual([])
const recoveryReports = errors.length
offline = false
const retry = page.getByRole("button", { name: "Try again", exact: true })
await retry.focus()
await retry.press("Enter")
await expect(page.getByRole("heading", { level: 1, name: "Notes in this workspace" })).toBeVisible()
await expect(page.getByRole("checkbox", { name: "Review", exact: true })).toBeEnabled()
await page.waitForLoadState("networkidle")
expect(errors).toHaveLength(recoveryReports)
await page.screenshot({ path: info.outputPath("module-download-recovered.png") })
const networkPath = info.outputPath("module-download-network.json")
const consolePath = info.outputPath("module-download-console.json")
await writeFile(networkPath, JSON.stringify(responses, null, 2))
await writeFile(consolePath, JSON.stringify({ recoverableErrors: errors, messages: consoleMessages }, null, 2))
await info.attach("module-download-network.json", {
path: networkPath,
contentType: "application/json",
})
await info.attach("module-download-console.json", {
path: consolePath,
contentType: "application/json",
})
})