Files
yoi/web/workspace/test/hosting-browser/hosting-shell.browser.test.ts

338 lines
12 KiB
TypeScript

/// <reference lib="dom" />
// Production-shell browser behavior with synthetic HTTP projections only.
// Run against prebuilt assets; never builds or uses a real session/backend.
import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { chromium, type Page } from "playwright";
import {
startOwnedProcesses,
stopOwnedProcesses,
} from "../../../../tools/web-ux/src/processes.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const evidence = `${root}/target/web-ux/hosting/interactions`;
async function noOverflow(page: Page) {
assertEquals(
await page.evaluate(() => ({
page: document.documentElement.scrollWidth > innerWidth,
main: [...document.querySelectorAll("main")].some((main) =>
main.scrollWidth > main.clientWidth
),
})),
{ page: false, main: false },
);
}
Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown save outcome on production shell", async () => {
const processes = await startOwnedProcesses(
[{
id: "hosting-interactions",
command: "deno",
cwd: root,
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-net",
"--allow-read",
"web/workspace/test/hosting-browser/fixture-server.ts",
"15379",
"web/workspace/build",
],
readyUrl: "http://127.0.0.1:15379/health",
readyTimeoutMs: 10000,
}],
`${root}/tools/web-ux/scenarios/hosting-settings-light.json`,
evidence,
[],
);
const browser = await chromium.launch();
const results: unknown[] = [];
try {
for (const theme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme: theme,
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
const errors: string[] = [];
const consoleErrors: string[] = [];
const httpErrors: string[] = [];
const failedRequests: { url: string; error: string }[] = [];
page.on("pageerror", (error) => errors.push(String(error)));
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("response", (response) => {
if (response.status() >= 400) {
httpErrors.push(
`${response.status()} ${new URL(response.url()).pathname}`,
);
}
});
page.on(
"requestfailed",
(request) =>
failedRequests.push({
url: request.url(),
error: request.failure()?.errorText ?? "unknown",
}),
);
const settingsRequests: string[] = [];
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/hosting/settings") {
settingsRequests.push(request.method());
}
});
await page.goto("http://127.0.0.1:15379/?fixture=admin-on");
await page.getByRole("heading", { name: "Yoi", exact: true })
.waitFor();
if (
await page.getByRole("button", {
name: "Show sidebar",
exact: true,
}).isVisible()
) {
await page.getByRole("button", {
name: "Show sidebar",
exact: true,
}).click();
}
const nav = page.getByRole("navigation", { name: "Your account" });
await nav.getByRole("link", { name: "Hosting settings", exact: true })
.waitFor();
await nav.getByRole("link", { name: "Account", exact: true }).focus();
await page.keyboard.press("Tab");
assertEquals(
await page.locator(":focus").innerText(),
"Hosting settings",
);
assertEquals(
await page.locator(":focus").evaluate((element) =>
getComputedStyle(element).outlineStyle !== "none"
),
true,
);
await page.keyboard.press("Enter");
const edit = page.getByRole("button", {
name: "Edit registration policy",
});
await edit.waitFor();
// Globalの入口からhostingへ進んだ後はWorkspace shellを離れる。
// Workspace未作成でも管理できることと整合させる。
assertEquals(await nav.count(), 0);
await page.getByRole("heading", {
name: "Hosting settings",
exact: true,
}).waitFor();
if (
await page.getByRole("button", {
name: "Hide sidebar",
exact: true,
}).isVisible() &&
width < 768
) {
await page.getByRole("button", {
name: "Hide sidebar",
exact: true,
}).click();
}
await noOverflow(page);
await edit.focus();
await page.keyboard.press("Enter");
const checkbox = page.getByRole("checkbox", {
name: "Allow new account registration",
});
assertEquals(
await checkbox.evaluate((element) =>
element === document.activeElement
),
true,
);
assertEquals(await checkbox.isChecked(), true);
await page.keyboard.press("Space");
await page.getByRole("button", { name: "Cancel", exact: true })
.click();
assertEquals(
await edit.evaluate((element) =>
element === document.activeElement
),
true,
);
await page.keyboard.press("Enter");
assertEquals(await checkbox.isChecked(), true);
await page.keyboard.press("Space");
await page.keyboard.press("Tab");
assertEquals(
await page.locator(":focus").innerText(),
"Save settings",
);
await page.keyboard.press("Enter");
await page.getByRole("status").filter({
hasText: "Hosting settings saved.",
}).waitFor();
assertEquals(
await edit.evaluate((element) =>
element === document.activeElement
),
true,
);
await page.getByText("Disabled", { exact: true }).waitFor();
assertEquals(
settingsRequests.filter((method) => method === "PUT").length,
1,
);
await noOverflow(page);
for (const mode of ["ordinary", "anonymous"]) {
settingsRequests.length = 0;
await page.goto(
`http://127.0.0.1:15379/hosting/settings?fixture=${mode}`,
);
if (mode === "anonymous") {
await page.waitForURL("http://127.0.0.1:15379/");
await page.getByRole("heading", { name: "Yoi", exact: true })
.waitFor();
} else {await page.getByText(
"Hosting administrator access is required.",
{ exact: true },
).waitFor();}
assertEquals(await edit.count(), 0);
assertEquals(await checkbox.count(), 0);
assertEquals(
await nav.getByRole("link", {
name: "Hosting settings",
exact: true,
}).count(),
0,
);
assertEquals(settingsRequests, []);
await noOverflow(page);
}
await page.goto(
"http://127.0.0.1:15379/register?fixture=account-off",
);
await page.getByText(
"New account registration is disabled. Existing users can still log in.",
).waitFor();
assertEquals(
await page.getByRole("button", { name: "Register new account" })
.count(),
0,
);
await page.getByRole("link", {
name: "Log in with an existing account",
}).waitFor();
await noOverflow(page);
await page.goto(
"http://127.0.0.1:15379/account?fixture=ordinary-account-off",
);
await page.getByRole("button", { name: "Add passkey to alice" })
.waitFor();
assertEquals(
await page.getByRole("button", { name: "Add passkey to alice" })
.isEnabled(),
true,
);
await page.goto(
"http://127.0.0.1:15379/hosting/settings?fixture=admin-save-error",
);
await edit.click();
await checkbox.press("Space");
await page.getByRole("button", { name: "Save settings", exact: true })
.click();
await page.getByRole("alert").waitFor();
assertEquals(await checkbox.isChecked(), false);
assertEquals(await checkbox.isDisabled(), true);
assertEquals(
await page.getByRole("button", {
name: "Save settings",
exact: true,
}).isDisabled(),
true,
);
await noOverflow(page);
await page.getByRole("button", {
name: "Reload settings",
exact: true,
}).click();
await edit.waitFor();
assertEquals(await page.getByRole("alert").count(), 0);
assertEquals(await edit.isEnabled(), true);
for (
const [mode, path, ready] of [
[
"admin-load-error",
"/hosting/settings",
'button:has-text("Retry loading settings")',
],
[
"account-error",
"/register",
'button:has-text("Retry registration availability")',
],
[
"admin-loading",
"/hosting/settings",
'[role="status"]:has-text("Loading hosting settings")',
],
[
"account-loading",
"/register",
'[role="status"]:has-text("Checking registration availability")',
],
]
) {
await page.goto(`http://127.0.0.1:15379${path}?fixture=${mode}`);
await page.locator(ready).waitFor();
await noOverflow(page);
}
// 遷移でidentity/settingsの読み取りや遅延font取得がbrowserに取消されることはある。
// 通信失敗一般を許すのではなく、これらの明示的な取消だけを区別する。
assert(
failedRequests.every(({ url, error }) =>
error === "net::ERR_ABORTED" && ([
"/api/hosting/settings",
"/api/auth/registration-policy",
"/api/auth/whoami",
"/api/hosting/access",
].some((path) => url.endsWith(path)) ||
/^\/_app\/immutable\/assets\/[^/]+\.woff2$/.test(
new URL(url).pathname,
))
),
JSON.stringify(failedRequests),
);
assertEquals(httpErrors, [
"503 /api/hosting/settings",
"503 /api/hosting/settings",
"503 /api/auth/registration-policy",
]);
assertEquals(consoleErrors.length, 3);
assert(consoleErrors.every((message) => message.includes("503")));
assertEquals(errors, []);
results.push({
theme,
width,
keyboard: "pass",
overflow: "none",
deniedControls: "absent",
save: "confirmed",
unconfirmedSave: "blocked until reload",
realAuthorizationTested: false,
});
} finally {
await context.close();
}
}
}
await Deno.writeTextFile(
`${evidence}/results.json`,
JSON.stringify(results, null, 2),
);
} finally {
await browser.close();
await stopOwnedProcesses(processes);
}
});