diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index 203f494e1f..2b66789ea5 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -13653,6 +13653,27 @@ async function boot(parsed, bpm = 60, resolution, debug) { return; } + // ๐Ÿ”ค The prompt's sign-in: run one step in this thread (it needs the DOM + // for the email-code exchange) and hand the plain result back. + if (type === "signup:step") { + const { action, ...data } = content || {}; + // Let the phone's keyboard offer the right autofill for the next answer. + if (action === "field" && keyboard?.input) { + keyboard.input.autocomplete = data.field === "email" ? "email" : data.field === "code" ? "one-time-code" : "off"; + keyboard.input.inputMode = data.field === "code" ? "numeric" : data.field === "email" ? "email" : "text"; + return; + } + if (!window.acSignup?.step) { + send({ type: "signup:result", content: { action, ok: false, reason: "fallback" } }); + return; + } + window.acSignup.step(action, data).then( + (result) => send({ type: "signup:result", content: { action, ...result } }), + (error) => send({ type: "signup:result", content: { action, ok: false, reason: "error", message: error?.message } }), + ); + return; + } + // Authenticate / signup or login a user. if (type === "login") { if (window.self !== window.top) { diff --git a/system/public/aesthetic.computer/boot.mjs b/system/public/aesthetic.computer/boot.mjs index 7a890c7267..5252b77271 100644 --- a/system/public/aesthetic.computer/boot.mjs +++ b/system/public/aesthetic.computer/boot.mjs @@ -1595,7 +1595,18 @@ if (!sandboxed && !window.acNOAUTH) { if (redirect || new URLSearchParams(location.search).get("login") === "password") { return redirectLogin(mode); } - window.acSignup.open(mode === "signup" ? "signup" : "login"); + // ๐Ÿ”ค Joining happens at the prompt (prompt.mjs `runJoin`): arrive there + // with `signup` / `login` already run, so it asks for the handle or email. + // A prompt too old to know these words would hand them straight back here, + // so a second request within a few seconds takes the hosted page instead. + let lastJump = 0; + try { lastJump = Number(sessionStorage.getItem("ac:join-jump")) || 0; } catch {} + if (window.self === window.top && typeof window.acDISK_SEND === "function" && Date.now() - lastJump > 8000) { + try { sessionStorage.setItem("ac:join-jump", String(Date.now())); } catch {} + window.acDISK_SEND({ type: "jump", content: { piece: `prompt~${mode === "signup" ? "signup" : "login"}~!autorun` } }); + return; + } + return redirectLogin(mode); }; } diff --git a/system/public/aesthetic.computer/disks/prompt.mjs b/system/public/aesthetic.computer/disks/prompt.mjs index a7a7420628..d55d21a105 100644 --- a/system/public/aesthetic.computer/disks/prompt.mjs +++ b/system/public/aesthetic.computer/disks/prompt.mjs @@ -237,6 +237,155 @@ let clearBtnConfirmTimeout = null; // Reset timer for confirmation let curtainHoverTarget = null; // Sound once when entering a visible curtain action. let resendVerificationText; + +// ๐Ÿ”ค Joining, entirely at the prompt. Logged out, `handle @name` holds a name, +// `email you@x.com` mails six digits, `code 123456` signs in and claims it; +// `login` skips to email, `google` / `apple` open the provider, `password` +// falls back to the hosted page. Each answer pre-fills the next command. The +// browser-only work runs in bios (signup-flow.mjs `step`), which answers with +// a `signup:result` act event. +const JOIN_WORDS = ["signup", "imnew", "login", "hi", "handle", "email", "code", "google", "apple", "password"]; +let join = { stage: null, mode: null, email: null, handle: null }; + +function isJoinCommand(text) { + const t = text.trim(); + const word = t.split(/\s+/)[0].toLowerCase(); + if (JOIN_WORDS.includes(word)) return true; + if (join.stage === "code" && /^\d{6}$/.test(t)) return true; + if (join.stage === "email" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) return true; + return false; +} + +function joinFill({ send, system }, text, field = "text") { + const fill = () => { + system.prompt.input.text = text; + system.prompt.input.runnable = true; + system.prompt.input.snap(); + send({ type: "keyboard:text:replace", content: { text } }); + }; + firstActivation = false; + fill(); + // The prompt blanks itself after a command returns; restore the pre-fill + // only if that happened, so anything typed since is never overwritten. + setTimeout(() => { + if (!system.prompt.input.text) fill(); + }, 120); + send({ type: "signup:step", content: { action: "field", field } }); + send({ type: "keyboard:unlock" }); + send({ type: "keyboard:open" }); +} + +const joinSend = ({ send }, action, data = {}) => + send({ type: "signup:step", content: { action, ...data } }); + +function joinStart($, mode) { + join = { stage: mode === "login" ? "email" : "handle", mode, email: null, handle: null }; + joinSend($, "start", { mode }); + if (mode === "login") { + joinFill($, "email ", "email"); + $.notice("YOUR EMAIL?", ["yellow", "blue"]); + } else { + joinFill($, "handle @"); + $.notice("PICK A HANDLE", ["yellow", "blue"]); + } +} + +function runJoin($, text) { + const t = text.trim(); + const [first, ...rest] = t.split(/\s+/); + const word = first.toLowerCase(); + const arg = rest.join(" ").trim(); + if (join.stage === "code" && /^\d{6}$/.test(t)) return joinCode($, t); + if (join.stage === "email" && !JOIN_WORDS.includes(word) && t.includes("@")) return joinEmail($, t); + switch (word) { + case "signup": + case "imnew": + return joinStart($, "signup"); + case "login": + case "hi": + return joinStart($, "login"); + case "handle": { + const name = arg.replace(/^@/, ""); + if (!name) return joinStart($, "signup"); + join.mode = "signup"; + $.notice("CHECKING @" + name.toUpperCase(), ["yellow", "blue"]); + return joinSend($, "hold", { handle: name }); + } + case "email": + if (!arg) return joinStart($, join.mode || "login"); + return joinEmail($, arg); + case "code": + if (!join.email) return joinStart($, "login"); + return joinCode($, arg); + case "google": + case "apple": + $.notice("OPENING " + word.toUpperCase(), ["yellow", "blue"]); + return joinSend($, "provider", { connection: word === "google" ? "google-oauth2" : "apple" }); + case "password": + return joinSend($, "password", { mode: join.mode || "login" }); + } +} + +function joinEmail($, email) { + join.email = email; + $.notice("SENDING CODE", ["yellow", "blue"]); + joinSend($, "email", { email, mode: join.mode || "login" }); +} + +function joinCode($, code) { + const digits = String(code || "").replace(/\D/g, ""); + if (digits.length !== 6) { + $.notice("SIX DIGITS", ["yellow", "red"]); + return joinFill($, "code ", "code"); + } + $.notice("CHECKING CODE", ["yellow", "blue"]); + joinSend($, "code", { email: join.email, code: digits }); +} + +// The answer to a step, back from bios. +function joinResult($, r = {}) { + const { notice } = $; + if (r.redirected) return; + if (r.done) { + join = { stage: null, mode: null, email: null, handle: null }; + notice("HI @" + String(r.handle).toUpperCase(), ["lime", "green"]); + setTimeout(() => joinSend($, "finish", { destination: r.destination }), 1400); + return; + } + if (r.needsHandle) { + join.stage = "handle"; + notice(r.reason === "taken" ? "TAKEN ยท PICK ANOTHER" : "PICK A HANDLE", ["yellow", "blue"]); + return joinFill($, "handle @"); + } + if (r.reason === "fallback") { + notice("TYPE: password", ["yellow", "red"]); + return joinFill($, "password"); + } + switch (r.action) { + case "hold": + if (r.ok) { + join.stage = "email"; + join.handle = r.handle; + notice("@" + r.handle.toUpperCase() + " IS YOURS", ["lime", "green"]); + return joinFill($, "email ", "email"); + } + notice(r.reason === "taken" ? "@" + String(r.handle).toUpperCase() + " IS TAKEN" : + r.reason === "invalid" ? "LETTERS + NUMBERS" : "TRY AGAIN", ["yellow", "red"]); + return joinFill($, "handle @"); + case "email": + if (r.ok) { + join.stage = "code"; + notice("CODE SENT ยท CHECK EMAIL", ["lime", "green"]); + return joinFill($, "code ", "code"); + } + notice((r.message || "COULDN'T SEND").toUpperCase(), ["yellow", "red"]); + return joinFill($, "email " + (join.email || ""), "email"); + case "code": + case "provider": + notice(r.action === "code" ? "WRONG CODE ยท TRY AGAIN" : "SIGN IN DIDN'T FINISH", ["yellow", "red"]); + if (r.action === "code") return joinFill($, "code ", "code"); + } +} let ellipsisTicker; let chatTicker; // Ticker instance for chat messages let chatTickerButton; // Button for chat ticker hover interaction @@ -3898,6 +4047,12 @@ async function halt($, text) { store["keep:piece"] = code; jump(`keep~$${code}`); return true; + } else if (!user && !net.iframe && isJoinCommand(text)) { + // ๐Ÿ”ค Logged out: handle / email / code / login / google / apple join here. + runJoin({ send, system, notice }, text); + flashColor = [255, 255, 0, 100]; // Yellow + makeFlash($); + return true; } else if (text.startsWith("email")) { // Set user email. const email = text.split(" ")[1]; @@ -8145,6 +8300,12 @@ function act({ send({ type: "keyboard:lock" }); return; } + // ๐Ÿ”ค A join step finished in bios (see `runJoin`). + if (e.is("signup:result")) { + joinResult({ send, system, notice }, e.content); + needsPaint(); + return; + } // ๐ŸŽž๏ธ Rolodex: a predominantly-vertical drag on the focused prompt // scrubs command history pixel-by-pixel and stays where you let go // (the snap). Only engages past a threshold so taps / horizontal @@ -8355,7 +8516,8 @@ function act({ down: () => downSound(), push: () => { pushSound(); - net.login(); + if (!net.iframe) joinStart({ send, system, notice }, "login"); + else net.login(); // if (net.iframe) jump("login-wait"); }, cancel: () => cancelSound(), @@ -8369,7 +8531,7 @@ function act({ down: () => downSound(), push: () => { pushSound(); - net.signup(); + joinStart({ send, system, notice }, "signup"); // if (net.iframe) jump("signup-wait"); }, cancel: () => cancelSound(), diff --git a/system/public/aesthetic.computer/lib/disk.mjs b/system/public/aesthetic.computer/lib/disk.mjs index 2cad927b0a..4a7c9af8ce 100644 --- a/system/public/aesthetic.computer/lib/disk.mjs +++ b/system/public/aesthetic.computer/lib/disk.mjs @@ -11324,6 +11324,13 @@ async function makeFrame({ data: { type, content } }) { return; } + // ๐Ÿ”ค A step of the prompt's sign-in (signup-flow.mjs `step`) has finished + // in the main thread; the prompt reads it as an act event. + if (type === "signup:result") { + actAlerts.push({ name: "signup:result", content }); + return; + } + // ๐ŸŽต Clock piece sent its cached code for QR display if (type === "clock:cached") { cachedClockCode = content?.code || null; diff --git a/system/public/aesthetic.computer/lib/signup-flow.mjs b/system/public/aesthetic.computer/lib/signup-flow.mjs index 604d19e87e..a14963fbbe 100644 --- a/system/public/aesthetic.computer/lib/signup-flow.mjs +++ b/system/public/aesthetic.computer/lib/signup-flow.mjs @@ -441,7 +441,107 @@ export function createSignupFlow(win, doc, { clientId, redirect, social, socials } } + // ๐Ÿ”ค The prompt door: the same steps with no DOM at all. prompt.mjs turns + // `handle @name`, `email you@x`, `code 123456`, `google` and `apple` into + // `signup:step` messages; bios calls step() and posts the plain-data result + // back as `signup:result`. The prompt draws every word of the experience. + function ensureAttempt(mode = "signup") { + if (!read()) start(mode); + if (!attempt.hold) { attempt.hold = win.crypto.randomUUID(); persist(); } + return attempt; + } + + // Signed in (code or provider): a returning account is done; a newcomer + // claims the handle they held; otherwise the prompt asks for one. + async function afterSignIn(sub) { + let existing = null; + try { + const res = await win.fetch(`/handle?for=${encodeURIComponent(sub)}`, { cache: "no-store" }); + if (res.ok) existing = (await res.json()).handle || null; + } catch {} + if (existing) return { ok: true, done: true, handle: existing, destination: "/prompt", returning: true }; + if (read()?.handle) return claimQuietly(attempt.handle); + track("handle_shown"); + return { ok: true, needsHandle: true }; + } + + async function claimQuietly(handle) { + try { + const result = await request("/handle", { method: "POST", body: JSON.stringify({ handle, hold: read()?.hold }) }); + if (!result.handle) throw Object.assign(new Error("Missing handle"), { reason: "network" }); + win.dispatchEvent(new win.Event("ac:handle-created")); + return { ok: true, done: true, handle: result.handle, destination: signupReturnPath(read()?.returnTo, win.location.origin) || "/chat" }; + } catch (error) { + const reason = SIGNUP_ERRORS.includes(error.reason) ? error.reason : "network"; + track("handle_failed", reason); + if (attempt) { attempt.handle = null; persist(); } + return { ok: false, reason, needsHandle: true }; + } + } + + const tenantFault = (error) => !!otpModule?.tenantFaults?.includes(error?.code); + + async function step(action, data = {}) { + try { + if (!supported() || !clientId) return { ok: false, reason: "fallback" }; + if (action === "start") { + ensureAttempt(data.mode === "login" ? "login" : "signup"); + attempt.mode = data.mode === "login" ? "login" : "signup"; persist(); + return { ok: true }; + } + if (action === "hold") { + const handle = String(data.handle || "").trim().replace(/^@/, ""); + if (validateHandle(handle) !== "valid") return { ok: false, reason: "invalid", handle }; + if (signedIn || auth) return claimQuietly(handle); // already in: claim straight away + ensureAttempt("signup"); attempt.mode = "signup"; persist(); + const res = await win.fetch("/api/handle-hold", { method: "POST", cache: "no-store", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ handle, attempt: attempt.hold }) }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + const reason = body.status === "taken" || body.status === "held" ? "taken" : body.status === "invalid" ? "invalid" : "network"; + track("handle_failed", reason); + return { ok: false, reason, handle }; + } + attempt.handle = handle; attempt.holdUntil = Date.parse(body.until) || Date.now() + 600000; persist(); + track("handle_held"); + return { ok: true, handle, minutes: holdMinutes() }; + } + if (action === "email") { + ensureAttempt(data.mode || read()?.mode || "login"); + const email = await (await door()).sendCode(data.email); + track("code_sent"); + return { ok: true, email }; + } + if (action === "code") { + const session = await (await door()).verify(data.email, data.code); + signedIn = session; + adopt(session); + track("verified"); + return afterSignIn(session.sub); + } + if (action === "provider") { + ensureAttempt(read()?.mode || "signup"); + track("social_started"); + const client = await social?.(data.connection); + if (!client) return { ok: true, redirected: true }; + signedIn = null; otp?.forget(); auth = client; + const user = await client.getUser(); + if (!user?.sub) return { ok: false, reason: "auth" }; + track("verified"); + return afterSignIn(user.sub); + } + if (action === "finish") { complete(data.destination || "/prompt"); return { ok: true }; } + if (action === "password") { fallback(data.mode); return { ok: true, redirected: true }; } + return { ok: false, reason: "unknown" }; + } catch (error) { + if (tenantFault(error)) return { ok: false, reason: "fallback" }; + if (action === "code") track("code_failed", "code"); + return { ok: false, reason: error?.code || "error", message: error?.message || "That didnโ€™t work." }; + } + } + return { + step, start, track, resume, complete, pending: () => !!read(), close, open, remember(path) { const safe = signupReturnPath(path, win.location.origin); if (safe) previousPiece = safe; }, failed() { diff --git a/tests/browser/signup-journey.test.mjs b/tests/browser/signup-journey.test.mjs index f2039f5cdd..ff8efaf0aa 100644 --- a/tests/browser/signup-journey.test.mjs +++ b/tests/browser/signup-journey.test.mjs @@ -1,25 +1,28 @@ // signup-journey.test, 2026.10.08 -// End-to-end proof of the in-page door (lib/signup-flow.mjs): a fresh browser -// types `signup` at the prompt, picks a @handle (held by /api/handle-hold), -// gives mail+signuptest@aesthetic.computer, types the six digits Auth0 -// mails, and lands signed in with that handle โ€” without ever leaving -// aesthetic.computer. Then it logs out and back in by code (the returning-user -// path), and finally puts the account through delete-erase-and-forget-me. +// End-to-end proof of joining at the prompt (prompt.mjs `runJoin` โ†’ +// signup-flow.mjs `step`): a fresh browser types `signup`, then +// `handle @sut` (held by /api/handle-hold), then `email โ€ฆ` and the +// six-digit `code โ€ฆ` Auth0 mails โ€” every answer pre-fills the next command โ€” +// and lands signed in with that handle, without a dialog or a redirect. Then +// it types `logout`, comes back with `login` โ†’ `email` โ†’ `code` as the same +// account, and finally puts the account through delete-erase-and-forget-me. // // npm run test:signup:e2e # headless, production // AC_HEADED=1 npm run test:signup:e2e # watch it -// AC_SIGNUP_OVERLAY=1 npm run test:signup:e2e # production, but with this -// checkout's boot/bios/signup files swapped in and /api/handle-hold -// answered locally โ€” proves the client before it ships +// AC_SIGNUP_OVERLAY=1 npm run test:signup:e2e # production, with this +// checkout's boot/bios/prompt/signup files swapped in โ€” proves the client +// before it ships. AC_SIGNUP_WORKER_DIR= serves a freshly built disk worker too. // AC_SIGNUP_KEEP=1 ... # leave the test account alive // -// The code is read off jasellite (./jasellite-mail.mjs). The browser counts as -// automated, so the funnel files these attempts under automated. +// The prompt draws into a canvas, so the test reads its text off the hidden +// #software-keyboard-input that bios mirrors it into. Codes are read off +// jasellite (./jasellite-mail.mjs). The browser counts as automated. // // Puppeteer needs a Chrome; with none downloaded, point it at the installed // one: PUPPETEER_EXECUTABLE_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome". -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { ACSession, CONFIG, scenario, report } from "./ac-harness.mjs"; @@ -27,6 +30,7 @@ import { waitForLetter } from "./jasellite-mail.mjs"; const KEEP = process.env.AC_SIGNUP_KEEP === "1"; const OVERLAY = process.env.AC_SIGNUP_OVERLAY === "1"; +const WORKER_DIR = process.env.AC_SIGNUP_WORKER_DIR; const ROOT = join(dirname(fileURLToPath(import.meta.url)), "../.."); const stamp = Date.now().toString(36); @@ -34,187 +38,175 @@ const email = `mail+signuptest${stamp}@aesthetic.computer`; const handle = `sut${stamp}`; // โ‰ค 16 characters, letters and numbers const CODE = /verification code is:\s*(\d{6})/; -// โ”€โ”€โ”€ the browser โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const ac = await ACSession.open(); // a fresh profile: the incognito case const page = ac.page; if (OVERLAY) { - // Serve this checkout's copies of the files the door lives in, and stand in - // for the hold endpoint that isn't deployed yet. Auth0, the mailed code and - // /handle stay real. - const local = { - "/aesthetic.computer/boot.mjs": "system/public/aesthetic.computer/boot.mjs", - "/aesthetic.computer/bios.mjs": "system/public/aesthetic.computer/bios.mjs", - "/aesthetic.computer/lib/signup-flow.mjs": "system/public/aesthetic.computer/lib/signup-flow.mjs", - "/aesthetic.computer/lib/signup-model.mjs": "system/public/aesthetic.computer/lib/signup-model.mjs", - "/aesthetic.computer/lib/auth0-otp.mjs": "system/public/aesthetic.computer/lib/auth0-otp.mjs", - }; + const A = "system/public/aesthetic.computer"; + const local = Object.fromEntries(["boot.mjs", "bios.mjs", "disks/prompt.mjs", "lib/signup-flow.mjs", + "lib/signup-model.mjs", "lib/auth0-otp.mjs"].map((f) => [`/aesthetic.computer/${f}`, join(ROOT, A, f)])); + let workerBundle = null; + if (WORKER_DIR) { + const manifest = JSON.parse(readFileSync(join(WORKER_DIR, "disk-worker-manifest.json"), "utf8")); + local["/aesthetic.computer/lib/disk-worker-manifest.json"] = join(WORKER_DIR, "disk-worker-manifest.json"); + workerBundle = join(WORKER_DIR, manifest.filename); + } await page.setBypassServiceWorker(true); await page.setRequestInterception(true); page.on("request", (request) => { const url = new URL(request.url()); - if (url.host !== new URL(CONFIG.baseURL).host) return request.continue(); - if (local[url.pathname]) { - return request.respond({ status: 200, contentType: "text/javascript; charset=utf-8", - headers: { "cache-control": "no-store" }, body: readFileSync(join(ROOT, local[url.pathname]), "utf8") }); - } - if (url.pathname === "/api/handle-hold") { - const body = request.method() === "POST" ? { held: true, until: new Date(Date.now() + 600000).toISOString() } : { status: "free" }; - return request.respond({ status: 200, contentType: "application/json", body: JSON.stringify(body) }); - } - request.continue(); + let file = url.host === new URL(CONFIG.baseURL).host && local[url.pathname]; + // The page may name the worker bundle without reading the manifest. + if (!file && WORKER_DIR && /\/lib\/disk\.worker\.[0-9a-f]+\.mjs$/.test(url.pathname)) file = workerBundle; + if (!file || !existsSync(file)) return request.continue(); + request.respond({ status: 200, headers: { "cache-control": "no-store" }, + contentType: file.endsWith(".json") ? "application/json" : "text/javascript; charset=utf-8", + body: readFileSync(file, "utf8") }); }); } const stages = []; page.on("request", (request) => { if (!request.url().endsWith("/api/signup-track")) return; - try { - const event = JSON.parse(request.postData()); - for (const stage of event.stages) if (!stages.includes(stage)) stages.push(stage); - } catch {} + try { for (const s of JSON.parse(request.postData()).stages) if (!stages.includes(s)) stages.push(s); } catch {} }); async function reached(stage, timeout = 30_000) { const deadline = Date.now() + timeout; while (!stages.includes(stage)) { - if (Date.now() > deadline) throw new Error(`signup never reached "${stage}" (got ${stages.join(" โ†’ ") || "nothing"})`); + if (Date.now() > deadline) throw new Error(`never reached "${stage}" (got ${stages.join(" โ†’ ") || "nothing"})`); await ac.wait(250); } } -const dialog = () => page.evaluate(() => { - const d = document.querySelector("dialog.ac-signup"); - return d?.open ? { title: d.querySelector("h1")?.textContent || "", text: d.innerText, status: d.querySelector("[role=status]")?.textContent || "" } : null; -}); -async function dialogTitled(pattern, timeout = 20_000) { +const promptText = () => page.evaluate(() => document.getElementById("software-keyboard-input")?.value ?? null); +async function promptIs(text, timeout = 30_000) { const deadline = Date.now() + timeout; let last; while (Date.now() < deadline) { - last = await dialog().catch(() => null); - if (last && pattern.test(last.title)) return last; + last = await promptText().catch(() => null); + if (last === text) return; await ac.wait(200); } - throw new Error(`expected a dialog titled ${pattern}, saw ${last ? `"${last.title}" (${last.status})` : "none"}`); + throw new Error(`prompt never read "${text}" (last "${last}")`); } -async function typeInto(selector, text) { - await page.waitForSelector(selector, { visible: true, timeout: 10_000 }); - await page.click(selector, { clickCount: 3 }); - await page.type(selector, text, { delay: 25 }); -} -const recent = []; -page.on("console", (m) => { recent.push(m.text().slice(0, 140)); if (recent.length > 40) recent.shift(); }); -const otpToken = () => page.evaluate(() => { try { return JSON.parse(localStorage.getItem("ac-otp-session"))?.access || null; } catch { return null; } }); -let token = null; +const ready = () => page.waitForFunction(() => window.preloaded === true, { timeout: 60_000 }).catch(() => {}); const signedInAs = () => page.evaluate(() => window.acUSER?.sub || null); -// The prompt can swallow keys typed while it is still settling, so give a -// command a few tries before deciding the door didn't open. -async function command(text, title) { +const otpToken = () => page.evaluate(() => { try { return JSON.parse(localStorage.getItem("ac-otp-session"))?.access || null; } catch { return null; } }); + +// Type a whole command at a fresh prompt. A stalled boot ignores keys, and +// Backspace on an empty prompt navigates, so retries reload instead. +async function command(text, expectPrompt) { for (let tries = 0; tries < 3; tries++) { - // A stalled boot ignores typing, and a half-typed command can't be cleared - // with Backspace (on an empty prompt it navigates), so retries reload. if (tries) await ac.boot("prompt"); - // bios sets `preloaded` once a real piece (not the blank init disk) boots. - await page.waitForFunction(() => window.preloaded === true, { timeout: 60_000 }).catch(() => {}); + await ready(); await ac.wait(1200); await ac.focusPrompt(); await ac.type(text); await ac.wait(300); await ac.press("Enter"); - try { return await dialogTitled(title, 8000); } catch {} + try { return await promptIs(expectPrompt, 10_000); } catch {} } - await ac.shot(`signup-code-${text}-missing`); - console.log(" last console:", recent.slice(-6).join(" โ€– ")); - return dialogTitled(title, 1); + return promptIs(expectPrompt, 1); +} +// Answer a pre-filled command: the cursor sits at its end, so just type on. +async function answer(text) { + await ac.type(text); + await ac.wait(300); + await ac.press("Enter"); } -// Each step depends on the one before, so the first failure skips the rest. let broken = false; const step = (name, fn) => scenario(name, async (expect) => { if (broken) return expect(false, "skipped โ€” an earlier step failed"); try { expect(true, await fn()); } catch (error) { broken = true; throw error; } }); -let sub = null, codeAsked = 0; +let sub = null, token = null, asked = 0; const t0 = Date.now(); const elapsed = () => `${((Date.now() - t0) / 1000).toFixed(1)}s`; -console.log(`\n๐Ÿงช signup journey (email code${OVERLAY ? ", local overlay" : ""}) ยท ${email} ยท @${handle}\n`); +console.log(`\n๐Ÿงช signup journey (prompt${OVERLAY ? ", local overlay" : ""}) ยท ${email} ยท @${handle}\n`); -await step("prompt `signup` opens the handle step in the page", async () => { +await step("`signup` pre-fills `handle @` at the prompt", async () => { await ac.boot("prompt"); - await command("signup", /Pick your @handle/); - if (new URL(page.url()).host !== new URL(CONFIG.baseURL).host) throw new Error(`left the site for ${page.url()}`); + await command("signup", "handle @"); await reached("started", 5000); - await ac.shot("signup-code-handle"); - return `in-page at ${elapsed()}`; + if (await page.$("dialog.ac-signup[open]")) throw new Error("a dialog opened"); + await ac.shot("join-1-handle"); + return `at ${elapsed()}`; }); -await step(`@${handle} checks free and is held`, async () => { - await typeInto("#ac-signup-handle", handle); - await page.waitForFunction(() => /is free/.test(document.querySelector("#ac-signup-check")?.textContent || ""), { timeout: 10_000 }); - await page.click("dialog.ac-signup .primary"); - await dialogTitled(/Where should we send a code/); - await reached("handle_held", 5000); - const hold = await page.evaluate(() => document.querySelector("dialog.ac-signup .hold")?.textContent); - return hold || "held"; +await step(`\`handle @${handle}\` is held and pre-fills \`email \``, async () => { + await answer(handle); + await reached("handle_held", 15_000); + await promptIs("email "); + await ac.shot("join-2-email"); + return `held at ${elapsed()}`; }); -await step("the email step sends a six-digit code", async () => { - await typeInto("#ac-signup-email", email); - codeAsked = Date.now(); - await page.click("dialog.ac-signup .primary"); - const view = await dialogTitled(/Enter the code/, 20_000); - await reached("code_sent", 5000); - await ac.shot("signup-code-enter"); - return view.text.split("\n").find((l) => l.includes("Sent to")) || "sent"; +await step("`email โ€ฆ` sends a code and pre-fills `code `", async () => { + asked = Date.now(); + await answer(email); + await reached("code_sent", 20_000); + await promptIs("code "); + await ac.shot("join-3-code"); + return `at ${elapsed()}`; }); -await step("typing the mailed code signs in and claims the handle", async () => { - const code = await waitForLetter(email, CODE, { since: codeAsked }); - await typeInto("#ac-signup-code", code); // six digits submit on their own +await step("`code โ€ฆ` signs in and claims the handle", async () => { + await answer(await waitForLetter(email, CODE, { since: asked })); await reached("verified", 30_000); await reached("completed", 30_000); - await page.waitForFunction(() => !document.querySelector("dialog.ac-signup")?.open, { timeout: 20_000 }); - await page.waitForFunction(() => window.acUSER?.sub, { timeout: 30_000 }); + await page.waitForFunction(() => window.acUSER?.sub, { timeout: 60_000 }); sub = await signedInAs(); token = await otpToken(); await ac.wait(1500); - await ac.shot("signup-code-done"); - return `signed in as ${sub} at ${elapsed()}`; + await ac.shot("join-4-signed-in"); + return `${sub} at ${elapsed()}`; }); await step("the handle API knows the new account", async () => { - const res = await fetch(`${CONFIG.baseURL}/handle?for=${encodeURIComponent(sub)}`); - const json = await res.json().catch(() => ({})); - if (json.handle !== handle) throw new Error(`/handle?for= said ${res.status} ${JSON.stringify(json)}`); + const json = await (await fetch(`${CONFIG.baseURL}/handle?for=${encodeURIComponent(sub)}`)).json().catch(() => ({})); + if (json.handle !== handle) throw new Error(`/handle?for= said ${JSON.stringify(json)}`); return `@${json.handle}`; }); -await step("logging out and back in by code returns the same account", async () => { - // What the logout command does for a code session (bios.mjs). - await page.evaluate(() => { localStorage.removeItem("ac-otp-session"); localStorage.removeItem("session-aesthetic"); }); - await ac.boot("prompt"); - if (await signedInAs()) throw new Error("still signed in after forgetting the session"); - await command("login", /Log in/); - await typeInto("#ac-signup-email", email); - codeAsked = Date.now(); - await page.click("dialog.ac-signup .primary"); - await dialogTitled(/Enter the code/, 20_000); - const code = await waitForLetter(email, CODE, { since: codeAsked }); - await typeInto("#ac-signup-code", code); - await page.waitForFunction(() => window.acUSER?.sub, { timeout: 45_000 }); +await step("`logout` signs out", async () => { + for (let tries = 0; tries < 3; tries++) { + await ac.boot("prompt"); + await ready(); + await ac.wait(1500); + await ac.focusPrompt(); + await answer("logout"); + const out = await page.waitForFunction(() => !localStorage.getItem("ac-otp-session"), { timeout: 10_000 }).then(() => true, () => false); + if (out) break; + } + // logout reloads the page; poll across the reload until no one is signed in. + const deadline = Date.now() + 45_000; + while ((await signedInAs().catch(() => "reloading")) && Date.now() < deadline) await ac.wait(500); + await ready(); + if (await signedInAs()) throw new Error("still signed in"); + await ac.shot("join-5-logged-out"); + return "signed out"; +}); + +await step("`login` โ†’ `email` โ†’ `code` returns the same account", async () => { + await command("login", "email "); + asked = Date.now(); + await answer(email); + await promptIs("code ", 20_000); + await answer(await waitForLetter(email, CODE, { since: asked })); + await page.waitForFunction(() => window.acUSER?.sub, { timeout: 60_000 }); const again = await signedInAs(); token = (await otpToken()) || token; if (again !== sub) throw new Error(`came back as ${again}, not ${sub}`); + await ac.shot("join-6-returned"); return `${again} at ${elapsed()}`; }); -// Cleanup runs whenever an account exists, even after a failed step. if (!KEEP && sub) { broken = false; await step("delete-erase-and-forget-me locks the test account", async () => { if (!token) throw new Error("no token to delete with โ€” account left alive"); - const res = await fetch(`${CONFIG.baseURL}/api/delete-erase-and-forget-me`, { - method: "POST", headers: { Authorization: `Bearer ${token}` }, - }); + const res = await fetch(`${CONFIG.baseURL}/api/delete-erase-and-forget-me`, { method: "POST", headers: { Authorization: `Bearer ${token}` } }); const json = await res.json().catch(() => ({})); if (res.status !== 200) throw new Error(`${res.status} ${JSON.stringify(json)}`); return `purge after ${json.purgeAfter || "?"}`;