Files

405 lines
12 KiB
TypeScript

// shared captureがscreenshot/snapshot/review-contextを採取する。合否と本人目視は呼出元が行う。
// 本番buildに合成HTTP/WebAuthnだけを接続し、撮影専用CSSや認証情報は使わない。
import { resolve } from "@std/path";
import { capture } from "../../../../tools/web-ux/src/capture.ts";
import type {
Interaction,
RouteScenario,
Scenario,
} from "../../../../tools/web-ux/src/types.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const phase = Deno.args[0] ?? "after";
const coverage = Deno.args[1] ?? "all";
if (coverage !== "all" && coverage !== "session") {
throw new Error("Expected capture coverage: all or session");
}
if (!/^[a-zA-Z0-9_-]+$/.test(phase)) {
throw new Error("Expected safe capture label");
}
const ready = (selector: string) => ({ kind: "selector" as const, selector });
const click = (selector: string): Interaction => ({
action: "click",
selector,
});
const fill = (selector: string, value: string): Interaction => ({
action: "fill",
selector,
value,
});
const id = 'label:has-text("ID (handle)") input';
const loginButton = 'button:text-is("Log in with passkey")';
const login = [click(loginButton)];
const legacyLogin = [
click('summary:text-is("Use an older key with account ID")'),
fill(id, "alice"),
click('button:text-is("Log in with older key")'),
];
const register = [
fill(id, "alice"),
fill('label:has-text("Display name") input', "Alice"),
click('button:text-is("Register new account")'),
];
const route = (
id: string,
path: string,
selector: string,
capturePoints: RouteScenario["capturePoints"] = [{
id: "initial",
label: id,
fullPage: true,
}],
): RouteScenario => ({
id,
label: id,
path,
goal:
"Review standalone production auth UI, navigation, focus, state and copy; not Server authorization.",
dataState: `Synthetic ${id}; no real session, credential or activation code.`,
ready: ready(selector),
capturePoints,
});
const routes: RouteScenario[] = [
route("login", "/login?fixture=anonymous", `${loginButton}:enabled`, [
{
id: "initial",
label: "ID-free primary login; explicit older-key disclosure",
fullPage: true,
},
{
id: "keyboard-focus",
label: "Login keyboard focus",
fullPage: true,
interaction: [{ action: "press", selector: loginButton, key: "Tab" }, {
action: "press",
selector: 'summary:text-is("Use an older key with account ID")',
key: "Shift+Tab",
}],
ready: ready('button:text-is("Log in with passkey"):focus-visible'),
},
{
id: "legacy-expanded",
label: "Explicit old-key compatibility and safe migration guidance",
fullPage: true,
interaction: [
click('summary:text-is("Use an older key with account ID")'),
],
ready: ready(`${id}:visible`),
},
]),
route(
"account-redirect",
"/account?fixture=anonymous",
'h1:text-is("Yoi")',
),
route(
"expired-account",
"/account?fixture=session-expired&tab=keys#passkeys",
'[role="status"]:has-text("Your browser session expired")',
),
route(
"expired-login",
"/login?fixture=session-expired-cancel-once&returnTo=%2Faccount%3Ftab%3Dkeys%23passkeys",
'[role="status"]:has-text("Your browser session expired")',
[
{
id: "initial",
label: "Explicit expiry and login continuation, not anonymous entry",
fullPage: true,
},
{
id: "cancelled",
label: "Cancelled passkey keeps expiry context and retry destination",
fullPage: true,
interaction: login,
ready: ready('[role="alert"]:has-text("cancelled")'),
},
{
id: "returned",
label:
"Retry confirms authentication before returning to the requested account",
fullPage: true,
interaction: login,
ready: ready('button:text-is("Add passkey to alice")'),
},
],
),
route("register", "/register?fixture=anonymous", `${id}:enabled`, [
{
id: "initial",
label: "ID and distinct display name; new account only",
fullPage: true,
},
{
id: "keyboard-focus",
label: "Register keyboard focus",
fullPage: true,
interaction: [
fill(id, "alice"),
fill('label:has-text("Display name") input', "Alice"),
{
action: "press",
selector: 'label:has-text("Display name") input',
key: "Tab",
},
],
ready: ready('button:text-is("Register new account"):focus-visible'),
},
]),
route(
"account",
"/account?fixture=account",
'button:text-is("Add passkey to alice")',
[
{
id: "initial",
label: "Own account only; no target handle field",
fullPage: true,
},
{
id: "addition-confirmed",
label: "Own Passkey addition confirmed",
fullPage: true,
interaction: [click('button:text-is("Add passkey to alice")')],
ready: ready('[role="status"]:has-text("Passkey added")'),
},
{
id: "logout",
label: "Log out forgets expiry and returns to public home",
fullPage: true,
interaction: [click('button:text-is("Log out")')],
ready: ready('h1:text-is("Yoi")'),
},
],
),
route(
"device-anonymous",
"/login/device?user_code=ABCD-EFGH&fixture=anonymous",
'a:text-is("Log in to continue")',
[
{
id: "initial",
label: "User code and login continuation; no implicit approval",
fullPage: true,
},
{
id: "login",
label: "Dedicated login with device returnTo",
fullPage: true,
interaction: [click('a:text-is("Log in to continue")')],
ready: ready(`${loginButton}:enabled`),
},
{
id: "authenticated-unapproved",
label: "Login complete; explicit approval still required",
fullPage: true,
interaction: login,
ready: ready('button:text-is("Approve device login"):enabled'),
},
{
id: "approved",
label: "Separate explicit approval confirmed",
fullPage: true,
interaction: [click('button:text-is("Approve device login")')],
ready: ready(
'text="Device login approved. You can return to the CLI."',
),
},
],
),
route(
"session-loading",
"/login?fixture=session-loading",
'text="Loading session…"',
),
route(
"session-error",
"/login?fixture=session-error",
'button:text-is("Retry session")',
),
route(
"account-session-error",
"/account?fixture=session-error",
"text=/Login status is unknown/",
),
route(
"register-session-error",
"/register?fixture=session-error",
'button:text-is("Retry session")',
),
route(
"device-session-error",
"/login/device?user_code=ABCD-EFGH&fixture=session-error",
'button:text-is("Retry session")',
),
route(
"policy-disabled",
"/register?fixture=policy-disabled",
'text="New account registration is disabled. Existing users can still log in."',
),
route("policy-error", "/register?fixture=policy-error", '[role="alert"]'),
route("policy-stopped", "/register?fixture=policy-stopped", `${id}:enabled`, [
{
id: "completion-403",
label:
"Policy refreshed to disabled after intentional registration HTTP403",
fullPage: true,
interaction: register,
ready: ready(
'text="New account registration is disabled. Existing users can still log in."',
),
},
]),
...["login", "registration", "addition"].flatMap((purpose) =>
["pending", "cancel", "unsupported", "error", "expired"].map((state) => {
const path = purpose === "registration"
? "/register"
: purpose === "addition"
? "/account"
: "/login";
const interaction = purpose === "login"
? login
: purpose === "registration"
? register
: [click('button:text-is("Add passkey to alice")')];
return route(
`${purpose}-${state}`,
`${path}?fixture=${purpose}-${state}`,
purpose === "addition"
? 'button:text-is("Add passkey to alice")'
: purpose === "login"
? `${loginButton}:enabled`
: `${id}:enabled`,
[{
id: "result",
label: `${purpose} ${state}; not a successful ceremony`,
fullPage: true,
interaction,
ready: ready(
state === "pending" ? '[role="status"]' : '[role="alert"]',
),
}],
);
})
),
...["pending", "cancel", "unsupported", "error", "expired"].map((state) =>
route(
`legacy-login-${state}`,
`/login?fixture=login-${state}`,
`${loginButton}:enabled`,
[{
id: "result",
label:
`Explicit compatibility ${state}; not confirmed login or enrollment`,
fullPage: true,
interaction: legacyLogin,
ready: ready(
state === "pending" ? '[role="status"]' : '[role="alert"]',
),
}],
)
),
route(
"activation-entry",
"/login?fixture=activation-pending",
'a:text-is("Activate an account with a code")',
[
{
id: "entry",
label: "Existing activation route from login",
fullPage: true,
interaction: [click('a:text-is("Activate an account with a code")')],
ready: ready("#activation-code:enabled"),
},
{
id: "limited",
label:
"No ordinary session or device approval before Passkey completion",
fullPage: true,
interaction: [
fill("#activation-handle", "alice"),
fill("#activation-code", "synthetic-input-only"),
click('button:text-is("Register Passkey and activate")'),
],
ready: ready('button:text-is("Cancel registration")'),
},
{
id: "cancelled",
label: "Activation cancellation and login recovery entry",
fullPage: true,
interaction: [click('button:text-is("Cancel registration")')],
ready: ready('[role="alert"]'),
},
],
),
];
const sessionCaptureIds = new Set([
"login",
"account-redirect",
"expired-account",
"expired-login",
"session-error",
"account-session-error",
]);
await Deno.mkdir(`${root}/target/web-ux`, { recursive: true });
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const scenario: Scenario = {
schemaVersion: 1,
id: `auth-${colorScheme}-${width}`,
title:
"Standalone authentication synthetic projection on production build",
baseUrl: "http://127.0.0.1:15392",
colorScheme,
reducedMotion: "reduce",
personas: [{
id: "fixture",
label: "Synthetic HTTP/WebAuthn only",
auth: { kind: "anonymous" },
}],
viewports: [{ width, height: 900, label: `${width}` }],
routes: coverage === "session"
? routes.filter((route) => sessionCaptureIds.has(route.id))
: routes,
redact: {
// 初期撮影後にもfillする。空の入力まで恒久的に隠すと次の操作が不能になる。
// 合成codeはsubmit直後に破棄され、pendingのdisabled入力だけを隠す。
selectors: ["#activation-code:disabled", ".secret"],
text: ["synthetic-input-only"],
},
processes: [{
id: "auth-projection",
command: "deno",
cwd: root,
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-read",
"--allow-net",
"web/workspace/test/auth-browser/fixture-server.ts",
"15392",
"web/workspace/build",
],
readyUrl: "http://127.0.0.1:15392/health",
}],
};
const scenarioPath =
`${root}/target/web-ux/auth-${phase}-${colorScheme}-${width}.json`;
await Deno.writeTextFile(scenarioPath, JSON.stringify(scenario, null, 2));
const result = await capture({
scenarioPath,
outputDirectory: `${root}/target/web-ux`,
runId: `auth-${phase}-${colorScheme}-${width}`,
});
console.log(
JSON.stringify({
colorScheme,
width,
status: result.status,
captures: result.captures.length,
}),
);
}
}