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

259 lines
9.7 KiB
TypeScript

/// <reference lib="dom" />
import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { chromium, type Locator } from "playwright";
import {
startOwnedProcesses,
stopOwnedProcesses,
} from "../../../../tools/web-ux/src/processes.ts";
const root = resolve(import.meta.dirname!, "../../../..");
// 本番shellとclientを接続する合成HTTP/WebAuthn試験。Server認証/atomic保存のE2Eではない。
Deno.test("activation standalone shell supports issuance, reissue, denial, restart and confirmed-only activation", async () => {
const processes = await startOwnedProcesses(
[{
id: "activation-browser",
command: "deno",
cwd: root,
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-read",
"--allow-net",
"web/workspace/test/activation-browser/fixture-server.ts",
"15381",
"web/workspace/build",
],
readyUrl: "http://127.0.0.1:15381/health",
}],
`${root}/tools/web-ux/scenarios/hosting-settings-light.json`,
`${root}/target/web-ux/activation-interactions/processes`,
[],
);
const browser = await chromium.launch();
try {
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme,
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
const requested: string[] = [];
const errors: string[] = [];
page.on("request", (request) => {
if (new URL(request.url()).pathname.startsWith("/api/")) {
requested.push(
`${request.method()} ${new URL(request.url()).pathname}`,
);
}
});
page.on("pageerror", () => errors.push("pageerror"));
const go = async (path: string, mode: string) => {
await page.goto(`http://127.0.0.1:15381${path}?fixture=${mode}`);
};
const noOverflow = async () =>
assertEquals(
await page.evaluate(() =>
document.documentElement.scrollWidth > innerWidth
),
false,
);
const linkStyle = (link: Locator) =>
link.evaluate((element) => {
const style = getComputedStyle(element);
const probe = document.createElement("span");
probe.hidden = true;
element.append(probe);
const token = (name: string) => {
probe.style.color = `var(${name})`;
return getComputedStyle(probe).color;
};
const result = {
color: style.color,
outline: style.outlineColor,
width: style.outlineWidth,
kind: style.outlineStyle,
underline: style.textDecorationLine,
strong: token("--text-strong"),
text: token("--text"),
accent: token("--accent"),
};
probe.remove();
return result;
});
const checkLink = async (link: Locator) => {
await page.mouse.move(0, 0);
const normal = await linkStyle(link);
assertEquals(normal.color, normal.strong);
assert(normal.underline.includes("underline"));
await link.hover();
const hovered = await linkStyle(link);
assertEquals(hovered.color, hovered.text);
await page.mouse.move(0, 0);
await page.keyboard.press("Tab");
await link.focus();
const focused = await linkStyle(link);
assertEquals(focused.kind, "solid");
assertEquals(focused.width, "2px");
assertEquals(focused.outline, focused.accent);
};
await go("/hosting/settings", "admin");
await page.getByRole("link", {
name: "Create accounts or reissue activation codes",
}).click();
await page.getByLabel("Account ID (handle)").waitFor();
await checkLink(
page.getByRole("link", { name: "Hosting settings", exact: true }),
);
const handle = page.getByLabel("Account ID (handle)");
await handle.fill(" ALICE ");
const issue = page.getByRole("button", {
name: "Create account and issue code",
});
await handle.press("Tab");
assert(
await issue.evaluate((el) =>
el === document.activeElement &&
getComputedStyle(el).outlineStyle !== "none"
),
);
await page.keyboard.press("Enter");
await page.getByRole("heading", { name: "Activation code issued" })
.waitFor();
await checkLink(
page.getByRole("link", { name: "/activate", exact: true }),
);
assertEquals(await page.locator(".secret").count(), 1);
await noOverflow();
await page.getByRole("button", { name: "Hide code" }).click();
assertEquals(await page.locator(".secret").count(), 0);
await page.getByLabel("Operation").selectOption("reissue");
await page.getByRole("button", { name: "Reissue activation code" })
.click();
await page.getByRole("heading", { name: "Activation code issued" })
.waitFor();
assert(
requested.includes("POST /api/hosting/accounts/activation-code"),
);
for (const mode of ["anonymous", "ordinary"]) {
const before = requested.filter((path) =>
path.startsWith("POST ")
).length;
await go("/hosting/accounts", mode);
if (mode === "anonymous") {
await page.waitForURL("http://127.0.0.1:15381/");
await page.getByRole("heading", { name: "Yoi", exact: true })
.waitFor();
} else {await page.getByText(
"Hosting administrator access is required.",
).waitFor();}
assertEquals(
await page.getByLabel("Account ID (handle)").count(),
0,
);
assertEquals(
requested.filter((path) => path.startsWith("POST ")).length,
before,
);
await noOverflow();
}
const fillActivation = async () => {
await page.getByLabel("Account ID (handle)").fill("alice");
await page.getByLabel("Activation code", { exact: true }).fill(
"synthetic-input-only",
);
await page.getByRole("button", {
name: "Register Passkey and activate",
}).click();
};
await go("/activate", "activation-pending");
await page.getByLabel("Activation code", { exact: true }).waitFor();
await checkLink(
page.getByRole("link", {
name:
"Already registered or completion uncertain? Log in with a Passkey",
}),
);
await fillActivation();
await page.getByText(
"Passkey registration pending. Account activation has not been confirmed.",
).waitFor();
assertEquals(
await page.getByLabel("Activation code", { exact: true })
.inputValue(),
"",
);
assertEquals(
requested.filter((path) =>
path === "POST /api/auth/activation/complete"
).length,
0,
);
await page.getByRole("button", { name: "Cancel registration" })
.click();
await page.getByRole("alert").waitFor();
assertEquals(await page.getByText(/You are signed in/).count(), 0);
await noOverflow();
await page.reload();
await page.getByRole("button", {
name: "Register Passkey and activate",
}).waitFor();
assertEquals(
await page.getByLabel("Activation code", { exact: true })
.inputValue(),
"",
);
for (
const mode of [
"activation-cancel",
"activation-unknown",
"activation-reject",
]
) {
await go("/activate", mode);
await fillActivation();
await page.getByRole("alert").waitFor();
assertEquals(await page.getByText(/You are signed in/).count(), 0);
await noOverflow();
}
await go("/activate", "activation-success");
await fillActivation();
await page.getByText(
"Passkey registration and activation confirmed for alice. You are signed in.",
).waitFor();
assertEquals(await page.locator(".app-shell").count(), 0);
await noOverflow();
assertEquals(
requested.some((path) => path.includes("/api/workspaces")),
false,
);
assertEquals(
requested.some((path) =>
path.includes("/addition/") || path.includes("/registration/") ||
path.includes("/device")
),
false,
);
assertEquals(
await page.evaluate(() =>
[...Object.values(localStorage), ...Object.values(sessionStorage)]
.some((value) => value.includes("synthetic-"))
),
false,
);
assertEquals(new URL(page.url()).searchParams.has("code"), false);
assertEquals(errors, []);
} finally {
await context.close();
}
}
}
} finally {
await browser.close();
await stopOwnedProcesses(processes);
}
});