@@ -0,0 +1,181 @@
|
||||
// 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",
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user