182 lines
8.1 KiB
TypeScript
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",
|
|
})
|
|
})
|