Files
kandev/apps/web/e2e/tests/layout/spa-resilience.spec.ts
T
José Almeida eab70052d2 fix(web): prevent blank screens during SPA recovery (#2024)
* fix(web): prevent blank screens during SPA recovery

* fix(web): address SPA recovery review feedback
2026-07-30 10:46:35 +01:00

161 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test } from "../../fixtures/test-base";
import type { ConsoleMessage, Page, Route } from "@playwright/test";
const SETTINGS_URL = "/settings/system/updates";
const SETTINGS_CHUNK_GLOB = "**/assets/settings-routes-*.js";
const SETTINGS_CHUNK_PATH = /^\/assets\/settings-routes-[A-Za-z0-9_-]+\.js$/;
type BrowserIssue = {
kind: "console" | "pageerror";
text: string;
url: string;
};
function assertSettingsChunk(route: Route) {
const pathname = new URL(route.request().url()).pathname;
expect(pathname).toMatch(SETTINGS_CHUNK_PATH);
expect(pathname).not.toMatch(/\/(?:index|main)-[A-Za-z0-9_-]+\.js$/);
}
function watchBrowserIssues(page: Page) {
const issues: BrowserIssue[] = [];
page.on("pageerror", (error) => {
issues.push({ kind: "pageerror", text: error.message, url: "" });
});
page.on("console", (message: ConsoleMessage) => {
const text = message.text();
const isCriticalWarning =
/maximum update depth/i.test(text) || /result of getSnapshot should be cached/i.test(text);
if (message.type() !== "error" && !isCriticalWarning) return;
issues.push({
kind: "console",
text,
url: message.location().url,
});
});
return issues;
}
function expectNoUnexpectedIssues(
issues: BrowserIssue[],
allowConsole: (issue: BrowserIssue) => boolean = () => false,
) {
const unexpected = issues.filter((issue) => issue.kind === "pageerror" || !allowConsole(issue));
expect(unexpected, JSON.stringify(unexpected, null, 2)).toEqual([]);
}
function trackSettingsDocuments(page: Page) {
let requests = 0;
page.on("request", (request) => {
if (
request.isNavigationRequest() &&
request.frame() === page.mainFrame() &&
new URL(request.url()).pathname === SETTINGS_URL
) {
requests += 1;
}
});
return () => requests;
}
function isExpectedChunkConsole(issue: BrowserIssue) {
if (issue.kind !== "console") return false;
const signal = `${issue.text}\n${issue.url}`;
return (
/settings-routes-[A-Za-z0-9_-]+\.js/i.test(signal) &&
/failed to load resource|dynamically imported module|module script/i.test(signal)
);
}
function isExpectedPersistentChunkConsole(issue: BrowserIssue) {
return (
isExpectedChunkConsole(issue) ||
(issue.kind === "console" &&
/\[app\] route render failed/i.test(issue.text) &&
/dynamically imported module|settings-routes-/i.test(issue.text))
);
}
test.describe("SPA route resilience", () => {
test("shows accessible loading while the fingerprinted Settings chunk is delayed", async ({
testPage,
}) => {
const issues = watchBrowserIssues(testPage);
let releaseChunk = () => {};
let observeChunk = () => {};
const released = new Promise<void>((resolve) => {
releaseChunk = resolve;
});
const observed = new Promise<void>((resolve) => {
observeChunk = resolve;
});
await testPage.route(SETTINGS_CHUNK_GLOB, async (route) => {
assertSettingsChunk(route);
observeChunk();
await released;
await route.continue();
});
const navigation = testPage.goto(SETTINGS_URL);
try {
await observed;
await expect(
testPage.getByRole("status").filter({ hasText: "Loading Settings…" }),
).toBeVisible();
} finally {
releaseChunk();
}
await navigation;
await expect(testPage.getByTestId("system-page-title")).toHaveText("Updates");
expectNoUnexpectedIssues(issues);
});
test("reloads once after the first Settings chunk abort and then renders Settings", async ({
testPage,
}) => {
const issues = watchBrowserIssues(testPage);
const documentRequests = trackSettingsDocuments(testPage);
let chunkRequests = 0;
await testPage.route(SETTINGS_CHUNK_GLOB, async (route) => {
assertSettingsChunk(route);
chunkRequests += 1;
if (chunkRequests === 1) {
await route.abort("failed");
return;
}
await route.continue();
});
await testPage.goto(SETTINGS_URL);
await expect(testPage.getByTestId("system-page-title")).toHaveText("Updates");
expect(chunkRequests).toBe(2);
expect(documentRequests()).toBe(2);
expectNoUnexpectedIssues(issues, isExpectedChunkConsole);
});
test("stops after one reload and shows route recovery when the Settings chunk stays unavailable", async ({
testPage,
}) => {
const issues = watchBrowserIssues(testPage);
const documentRequests = trackSettingsDocuments(testPage);
let chunkRequests = 0;
await testPage.route(SETTINGS_CHUNK_GLOB, async (route) => {
assertSettingsChunk(route);
chunkRequests += 1;
await route.abort("failed");
});
await testPage.goto(SETTINGS_URL);
const recovery = testPage.getByRole("alert").filter({ hasText: "This page couldn’t load." });
await expect(recovery).toBeVisible();
await expect(recovery.getByRole("button", { name: "Reload" })).toBeVisible();
await expect(testPage.getByTestId("app-sidebar")).toBeVisible();
expect(chunkRequests).toBe(2);
expect(documentRequests()).toBe(2);
expectNoUnexpectedIssues(issues, isExpectedPersistentChunkConsole);
});
});