feat: separate login registration and own account flows
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
// 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";
|
||||
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 login = [
|
||||
fill(id, "alice"),
|
||||
click('button:text-is("Log in with passkey")'),
|
||||
];
|
||||
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", `${id}:enabled`, [
|
||||
{
|
||||
id: "initial",
|
||||
label: "ID-only login; registration and activation entries",
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "keyboard-focus",
|
||||
label: "Login keyboard focus",
|
||||
fullPage: true,
|
||||
interaction: [fill(id, "alice"), {
|
||||
action: "press",
|
||||
selector: id,
|
||||
key: "Tab",
|
||||
}],
|
||||
ready: ready('button:text-is("Log in with passkey"):focus-visible'),
|
||||
},
|
||||
]),
|
||||
route(
|
||||
"account-redirect",
|
||||
"/account?fixture=anonymous",
|
||||
'button:text-is("Log in with passkey")',
|
||||
),
|
||||
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 returns to login",
|
||||
fullPage: true,
|
||||
interaction: [click('button:text-is("Log out")')],
|
||||
ready: ready('button:text-is("Log in with passkey")'),
|
||||
},
|
||||
],
|
||||
),
|
||||
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(`${id}: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",
|
||||
'button:text-is("Retry session")',
|
||||
),
|
||||
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", "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")'
|
||||
: `${id}:enabled`,
|
||||
[{
|
||||
id: "result",
|
||||
label: `${purpose} ${state}; not a successful ceremony`,
|
||||
fullPage: true,
|
||||
interaction,
|
||||
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"]'),
|
||||
},
|
||||
],
|
||||
),
|
||||
];
|
||||
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,
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user