Public: anyone can read this; the crew can change it.

During grace, a forge's admins see one line with a pay link in its settings, and the operator sees grace on /forges #11487

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11394-during-grace-a-forge-s-admins-see-one-li → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/11394-during-grace-a-forge-s-admins-see-one-li was merged into main as 511a92b.

Review

Sign in to review this change.
10 files changed +181 −22
docs/features/plan-row.md +3 −1
@@ -1,7 +1,9 @@
11 # The Plan row in a forge's settings (#11141)
22
3 A forge's admins see one `status-rows` `dl` row (`Plan`) at the top of `/settings`: the plan's name and size, the crew credit used this month, and the status (`Active`, or `Past due, sleeps <date>`). With a billing portal set and a Stripe payer, **Manage billing** links to the provider's customer-portal sign-in (the buyer signs in with the email they paid with); **Change size** links to the host's `/pricing`. A forge with no notice (never told, or a host that is not its parent) shows no row.
3 A forge's admins see one `status-rows` `dl` row (`Plan`) at the top of `/settings`: the plan's name and size, the crew credit used this month, and the status (`Active`, or in grace `Payment due — paused on <day> · Pay`, #11394). With a billing portal set and a Stripe payer, **Manage billing** links to the provider's customer-portal sign-in (the buyer signs in with the email they paid with); **Change size** links to the host's `/pricing`. A forge with no notice (never told, or a host that is not its parent) shows no row.
44
5 - **Grace (#11394):** while a payment is late the row says `Payment due — paused on <day>` (the day grace ends, UTC) followed by one **Pay** link: the provider's portal when one is set, else the host's `/pricing`. Pay takes the place of Manage billing, and when Pay already goes to `/pricing`, Change size is dropped so the row never offers two links to one place. Home and board carry no notice.
6 - **Operator:** on the host's `/forges` (operator only) a forge in grace shows `grace until <YYYY-MM-DD>` in its row (`graceWords` in `src/spawn/clients.ts`); the terminal's forges screen says the same.
57 - **Host:** Settings → Billing → *Billing portal* sets `billing.portal_url` (https only, empty clears). Changing it tells every forge sent from here.
68 - **How a forge learns its plan:** the host writes a notice (`src/billing/notice.ts`) into that one forge's own `plan.json` (never `parent.json`) when the forge is made, a gift is redeemed, a plan is granted, or Stripe changes the subscription. The forge reads only its own file and calls nothing on the host, so it cannot read another forge's plan; the notice has no forge name, customer or subscription id.
79 - **Terminal:** the palette entry *Plan* (`src/tui/screens/planrow.ts`, any admin) shows the same plan line (`planLine`) and, for an admin who is a person, sets the billing portal address with Enter through `changePortalUrl`, as the page does.
src/billing/notice.ts +5 −1
@@ -15,6 +15,7 @@ import type { App } from "../app.ts";
1515 import { childDirOf, listChildrenNow } from "../actions/spawn.ts";
1616 import { getPortalUrl, parseLink } from "../settings/billing.ts";
1717 import { getPlan } from "../settings/plans.ts";
18 import { lapseFor } from "./lapse.ts";
1819 import { entitlementOf, getStored } from "./store.ts";
1920 import type { EntitlementStatus } from "./types.ts";
2021
@@ -81,6 +82,9 @@ export function tellPlan(app: App, forge: string): boolean {
8182 const e = entitlementOf(app.db, forge, child.size);
8283 const plan = getPlan(app.db, e.plan);
8384 const portal = getPortalUrl(app.db);
85 // In grace the forge is told the day it pauses (the end of grace), not the provider's period end.
86 const lapse = e.status === "grace" ? lapseFor(app.db, forge) : undefined;
87 const periodEnd = lapse?.state === "grace" ? Math.floor(lapse.graceUntil / 1000) : e.periodEnd;
8488 const paysAtProvider = getStored(app.db, forge)?.provider === "stripe";
8589 return keepNotice(childDirOf(app, child.bodyId), {
8690 name: plan?.name ?? e.plan,
@@ -88,7 +92,7 @@ export function tellPlan(app: App, forge: string): boolean {
8892 priceUsd: plan?.priceUsd ?? 0,
8993 creditUsd: plan?.creditUsd ?? 0,
9094 status: e.status,
91 ...(e.periodEnd === undefined ? {} : { periodEnd: e.periodEnd }),
95 ...(periodEnd === undefined ? {} : { periodEnd }),
9296 ...(paysAtProvider && portal !== "" ? { portalUrl: portal } : {}),
9397 });
9498 } catch {
src/spawn/clients.ts +18
@@ -22,6 +22,7 @@ import { lastHeard, type Beat } from "./heartbeat.ts";
2222 import { reachOf, reachWords, type ChildReach } from "./reach.ts";
2323 import type { Size } from "./sizes.ts";
2424 import { firstScreenLine } from "../core/next-steps.ts";
25 import { lapseFor } from "../billing/lapse.ts";
2526 import { forgeUse, type ForgeUse } from "./usage.ts";
2627
2728 /** What a spawned forge says about itself to anyone: nothing more. */
@@ -70,6 +71,8 @@ export interface ClientRow {
7071 readonly disk: ChildDisk;
7172 /** When its last heartbeat reached this forge, ISO; null when none has. */
7273 readonly lastHeartbeatAt: string | null;
74 /** Epoch ms its grace for a late payment ends; absent when it is not in grace. */
75 readonly graceUntil?: number;
7376 /** The first step its person has not taken, read from the child's heartbeat. */
7477 readonly nextStep: NextStep;
7578 }
@@ -245,6 +248,7 @@ export async function clientRows(app: App): Promise<ClientRow[]> {
245248 spendTodayUsd: beats[i]?.beat.spendTodayUsd ?? null,
246249 ceilingUsd: beats[i]?.beat.ceilingUsd ?? null,
247250 ...creditView(app, c.name),
251 ...graceView(app, c.name, now),
248252 size: c.size,
249253 use: forgeUse(c, childDirOf(app, c.bodyId)),
250254 disk: liveChildDisk(app.config.dataDir, c.bodyId, mountinfo),
@@ -395,3 +399,17 @@ export function spendWords(r: ClientRow): { words: string; warn: boolean } | nul
395399 ? { words: `${base} · ceiling reached`, warn: true }
396400 : { words: base, warn: share >= 0.8 };
397401 }
402
403 /** "grace until 2026-11-18" for a forge whose payment is late, as /forges and the terminal say it; null otherwise. */
404 export function graceWords(r: Pick<ClientRow, "graceUntil">): string | null {
405 return r.graceUntil === undefined ? null : `grace until ${new Date(r.graceUntil).toISOString().slice(0, 10)}`;
406 }
407
408 function graceView(app: App, forge: string, now: number): { graceUntil?: number } {
409 try {
410 const l = lapseFor(app.db, forge, now);
411 return l.state === "grace" ? { graceUntil: l.graceUntil } : {};
412 } catch {
413 return {};
414 }
415 }
src/tui/screens/forges.ts +2 −2
@@ -6,7 +6,7 @@
66
77 import type { Ctx, Screen } from "../app/app.ts";
88 import { binding } from "../app/keys.ts";
9 import { ATTENTION_WORDS, firstScreenOf, CLAIM_WORDS, claimState, creditWords, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts";
9 import { ATTENTION_WORDS, firstScreenOf, CLAIM_WORDS, claimState, creditWords, graceWords, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts";
1010 import { useLine } from "../../spawn/usage.ts";
1111 import { timeAgo } from "../../web/ui.ts";
1212 import { card } from "../ui/widgets.ts";
@@ -26,7 +26,7 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] {
2626 if (rows.length === 0) return [["", "You have not sent a forge yet"]];
2727 return rows.flatMap((r): [string, string][] => [[
2828 r.name,
29 [r.made ? (r.awake === true ? "awake" : "asleep") : null, r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], linkWords(r.link), runningWords(r), r.stage?.words ?? null, r.nextStep.words, spendWords(r)?.words ?? null, creditWords(r), useLine(r.size, r.use), r.made ? diskWords(r.disk) : null, r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`]
29 [r.made ? (r.awake === true ? "awake" : "asleep") : null, r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], linkWords(r.link), runningWords(r), r.stage?.words ?? null, r.nextStep.words, spendWords(r)?.words ?? null, creditWords(r), graceWords(r), useLine(r.size, r.use), r.made ? diskWords(r.disk) : null, r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`]

The terminal says graceWords, but no test shows a forge in grace producing grace until <date> in forgeLines. Both doors need a test that would fail without this. Add one to the TUI forges test.

3030 .filter((m): m is string => m !== null)
3131 .join(" · "),
3232 ], ...firstScreenLines(r), ...addressLines(r)]);
src/web/pages/forges.ts +2 −1
@@ -7,7 +7,7 @@
77 */
88
99 import type { Ctx } from "../../http/context.ts";
10 import { ATTENTION_WORDS, firstScreenOf, CLAIM_WORDS, claimState, clientRows, creditWords, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts";
10 import { ATTENTION_WORDS, firstScreenOf, CLAIM_WORDS, claimState, clientRows, creditWords, graceWords, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts";
1111 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
1212 import { notFoundPage, pageHead } from "../chrome.ts";
1313 import { html, type Html } from "../html.ts";
@@ -56,6 +56,7 @@ export function forgeRow(r: ClientRow, now: number): Html {
5656 runningNote(r),
5757 spendNote(r),
5858 creditWords(r) === null ? null : html`<span data-credit>${creditWords(r)}</span>`,
59 graceWords(r) === null ? null : html`<span data-grace>${graceWords(r)}</span>`,
5960 r.stage === null
6061 ? null
6162 : html`<span data-stage="${r.stage.key}"${r.stage.resend ? html` data-resend` : null}>${r.stage.words}${r.stage.at === null ? null : html` ${time(r.stage.at)}`}</span>`,
src/web/plan_row.ts +26 −12
@@ -1,11 +1,12 @@
11 /**
22 * The Plan row in a forge's settings (#11141), one `dl` row for its admins: the
3 * plan's name and size, the crew credit used this month, and the status (past
4 * due says the date it sleeps). Everything is what the host told this forge
5 * (`billing/notice.ts`) and its own funding file; nothing is asked of the
6 * host. "Manage billing" is the provider's portal sign-in, shown only when
7 * the host set one and this forge pays through it; "Change size" is the
8 * host's /pricing.
3 * plan's name and size, the crew credit used this month, and the status (in
4 * grace, "Payment due — paused on <day> · Pay"). Everything is what the host
5 * told this forge (`billing/notice.ts`) and its own funding file; nothing is
6 * asked of the host. "Manage billing" is the provider's portal sign-in, shown
7 * only when the host set one and this forge pays through it, and not in grace,
8 * where Pay takes its place; "Change size" is the host's /pricing, dropped
9 * when Pay already points there.
910 */
1011
1112 import { noticeOf, type PlanNotice } from "../billing/notice.ts";
@@ -15,11 +16,16 @@ import { sizeWords } from "../settings/billing.ts";
1516 import { isSize } from "../spawn/sizes.ts";
1617 import { html, type Html } from "./html.ts";
1718
18 const day = (secs: number): string => new Date(secs * 1000).toISOString().slice(0, 10);
19 const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
20 /** "18 Nov", in UTC. */
21 export const dayWords = (secs: number): string => {
22 const d = new Date(secs * 1000);
23 return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`;
24 };
1925
20 /** "Active", or "Past due, sleeps 2026-12-01". */
26 /** "Active", or "Payment due — paused on 18 Nov". */
2127 export function statusWords(n: Pick<PlanNotice, "status" | "periodEnd">): string {
22 if (n.status === "grace") return n.periodEnd === undefined ? "Past due" : `Past due, sleeps ${day(n.periodEnd)}`;
28 if (n.status === "grace") return n.periodEnd === undefined ? "Payment due" : `Payment due — paused on ${dayWords(n.periodEnd)}`;
2329 return n.status === "none" ? "No active plan" : "Active";
2430 }
2531
@@ -38,8 +44,16 @@ export function planRow(dataDir: string, parentOrigin: string | undefined, admin
3844 if (!admin) return null;
3945 const line = planLine(dataDir);
4046 if (line === undefined) return null;
41 const portal = noticeOf(dataDir)?.portalUrl;
47 const n = noticeOf(dataDir);
48 const portal = n?.portalUrl;
49 // In grace the one link that matters is Pay: the provider's portal, else the host's pricing page.
50 const pay = n?.status === "grace" ? (portal ?? (parentOrigin === undefined ? undefined : `${parentOrigin}/pricing`)) : undefined;
4251 return html`<dl class="status-rows"><div class="status-row"><dt>Plan</dt><dd>${line}${
43 portal === undefined ? "" : html` <a href="${portal}" rel="noopener noreferrer" target="_blank">Manage billing</a>`
44 }${parentOrigin === undefined ? "" : html` <a href="${parentOrigin}/pricing">Change size</a>`}</dd></div></dl>`;
52 pay === undefined ? "" : html` · <a href="${pay}" rel="noopener noreferrer">Pay</a>`
53 }${
54 portal === undefined || pay !== undefined ? "" : html` <a href="${portal}" rel="noopener noreferrer" target="_blank">Manage billing</a>`
55 }${
outdated

Principle 8/7: in grace with no portal, Pay and Change size are the same href. Show one.

56 // One link per place: when Pay already goes to the host's pricing page, Change size would repeat it.
57 parentOrigin === undefined || pay === `${parentOrigin}/pricing` ? "" : html` <a href="${parentOrigin}/pricing">Change size</a>`
58 }</dd></div></dl>`;
4559 }
test/grace-calm.test.ts added +101
@@ -0,0 +1,101 @@
1 /**
2 * Grace is one line where it belongs (#11394): the forge's own /settings Plan
3 * line says "Payment due — paused on <day> · Pay", the operator's /forges row
4 * says "grace until <date>", and the home and the board carry no notice.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { childDirOf } from "../src/actions/spawn.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { keepNotice, noticeOf, tellPlan } from "../src/billing/notice.ts";
13 import { putEntitlement } from "../src/billing/store.ts";
14 import { createSession } from "../src/core/sessions.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { clientRows, graceWords, type ClientRow } from "../src/spawn/clients.ts";
17 import { forgeLines } from "../src/tui/screens/forges.ts";
18 import { planLine } from "../src/web/plan_row.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20 import { seedAdmin } from "./helpers/admin.ts";
21
22 quietLogs();
23
24 const ELEMENT = /<[a-z][^<>]*?(?:\brole="alert"|\bclass="[^"]*\b(?:banner|notice|notice-danger|notice-warn|alert)\b[^"]*")[^<>]*>/gi;
25 const allowed = (tag: string): boolean => /\bclass="[^"]*\btoast\b/.test(tag) && (/\brole="status"/.test(tag) || /\bdata-tone="danger"/.test(tag));
26 const standing = (body: string): string[] => [...body.matchAll(ELEMENT)].map((m) => m[0]).filter((t) => !allowed(t));
27
28 let dir = "";
29 let app: App;
30 let servers: Servers;
31 let cookie = "";
32 const PORTAL = "https://billing.stripe.com/p/login/grace";
33
34 beforeAll(async () => {
35 dir = await mkdtemp(join(tmpdir(), "forge-grace-"));
36 app = createApp({ dataDir: dir, port: 0 });
37 servers = await serve(app);
38 const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
39 if (!admin.ok) throw new Error(admin.error);
40 cookie = `forge_session=${createSession(app.db, admin.value.id).token}`;
41 // This forge is itself told it is in grace (its own plan.json), and as a host it holds a child in grace.
42 await writeFile(join(dir, "parent.json"), JSON.stringify({ upstream: "https://host.example" }));
43 keepNotice(dir, { name: "Medium", size: "medium", priceUsd: 49, creditUsd: 10, status: "grace", periodEnd: Date.UTC(2026, 10, 18) / 1000, portalUrl: PORTAL });
44 await mkdir(join(dir, "spawn"), { recursive: true });
45 await writeFile(join(dir, "spawn", "alpha.json"), JSON.stringify({ name: "alpha", bodyId: "body-alpha", done: true, size: "small", org: "alpha", apps: [] }));
46 await mkdir(childDirOf(app, "body-alpha"), { recursive: true });
47 await writeFile(join(childDirOf(app, "body-alpha"), "parent.json"), JSON.stringify({ upstream: "https://host.example" }));
48 putEntitlement(app.db, { forge: "alpha", plan: "medium", status: "grace", periodEnd: 1_796_000_000, provider: "stripe", customerId: "cus_x", subscriptionId: "sub_x", source: "stripe" }, "small", "");
49 });
50
51 afterAll(async () => {
52 servers.stop();
53 app.close();
54 await rm(dir, { recursive: true, force: true });
55 });
56
57 const get = async (path: string): Promise<string> => {
58 const res = await fetch(`${servers.url}${path}`, { headers: { cookie }, redirect: "follow" });
59 expect(res.status).toBe(200);
60 return res.text();
61 };
62
63 test("the plan line says when it pauses, and settings carries it with a Pay link", async () => {
64 expect(planLine(dir)).toContain("Payment due — paused on 18 Nov");
65 const body = await get("/settings");
66 expect(body).toContain("Payment due — paused on 18 Nov");
67 expect(body).toMatch(new RegExp(`· <a href="${PORTAL}"[^>]*>Pay</a>`));
68 });
69
70 test("a told forge learns the day grace ends, not the provider's period end", () => {
71 expect(tellPlan(app, "alpha")).toBe(true);
72 const told = noticeOf(childDirOf(app, "body-alpha"))!;
73 expect(told.status).toBe("grace");
74 expect(told.periodEnd).toBeGreaterThan(Date.now() / 1000);
75 expect(told.periodEnd).not.toBe(1_796_000_000);
76 });
77
78 test("the operator's /forges row says grace until the date", async () => {
79 const body = await get("/forges");
80 expect(body).toMatch(/<span data-grace>grace until \d{4}-\d{2}-\d{2}<\/span>/);
81 expect(graceWords({})).toBeNull();
82 expect(graceWords({ graceUntil: Date.UTC(2026, 10, 18) })).toBe("grace until 2026-11-18");
83 });
84
85 test("in grace, the home and the board carry no notice and none of the words", async () => {
86 for (const path of ["/", "/board"]) {
87 const body = await get(path);
88 expect({ path, found: standing(body) }).toEqual({ path, found: [] });
89 expect(body).not.toContain("Payment due");
90 expect(body).not.toContain("grace until");
91 }
92 });
93
94 test("the terminal's forges screen says grace until the date for a forge in grace, and only then", async () => {
95 const rows = await clientRows(app);
96 const alpha = rows.find((r) => r.graceUntil !== undefined);
97 expect(alpha).toBeDefined();
98 const lines = forgeLines([alpha!]).map((l) => l[1]).join("\n");
99 expect(lines).toContain(`grace until ${new Date(alpha!.graceUntil!).toISOString().slice(0, 10)}`);
100 expect(forgeLines([{ ...alpha!, graceUntil: undefined } as unknown as ClientRow]).map((l) => l[1]).join("\n")).not.toContain("grace until");
101 });
test/plan-notice.test.ts +21 −3
@@ -85,10 +85,9 @@ describe("plan notice", () => {
8585 const row = planRow(a, "https://host.example", true)!.toString();
8686 expect(row.split("\n").length).toBe(1);
8787 expect(row).toContain("<dt>Plan</dt>");
88 expect(row).not.toContain(" · ");
88 expect(row).toContain(" · <a href=\"" + PORTAL + "\"");
8989 expect(row).toContain("Medium");
90 expect(row).toContain("Past due, sleeps 2026-");
91 expect(row).toContain(`href="${PORTAL}"`);
90 expect(row).toContain("Payment due — paused on ");
9291 expect(row).toContain('href="https://host.example/pricing"');
outdated

Whitespace-only line left where an assertion was deleted. Remove it.

9392 expect(planRow(a, "https://host.example", false)).toBeNull();
9493 // No portal URL for a forge that does not pay through Stripe: the plan, no link.
@@ -100,6 +99,25 @@ describe("plan notice", () => {
10099 expect(planRow(join(dir, "nowhere"), undefined, true)).toBeNull();
101100 });
102101
102 test("in grace with no portal, Pay goes to the host's pricing and Change size is not repeated", async () => {
103 const cd = await mkdtemp(join(tmpdir(), "forge-grace-row-"));
104 try {
105 await writeFile(join(cd, "plan.json"), JSON.stringify({ name: "Plan", size: "small", status: "grace", periodEnd: Date.UTC(2026, 10, 18) / 1000 }));
106 const row = planRow(cd, "https://host.example", true)!.toString();
107 expect(row).toContain('Payment due — paused on 18 Nov. · <a href="https://host.example/pricing"');
108 expect(row.match(/href="https:\/\/host\.example\/pricing"/g)?.length).toBe(1);
109 expect(row).not.toContain("Change size");
110 // With a portal, Pay goes there and Change size still offers the host's pricing.
111 await writeFile(join(cd, "plan.json"), JSON.stringify({ name: "Plan", size: "small", status: "grace", portalUrl: PORTAL }));
112 const withPortal = planRow(cd, "https://host.example", true)!.toString();
113 expect(withPortal).toContain(`· <a href="${PORTAL}"`);
114 expect(withPortal).toContain("Change size");
115 expect(withPortal).not.toContain("Manage billing");
116 } finally {
117 await rm(cd, { recursive: true, force: true });
118 }
119 });
120
103121 test("a hostile notice is refused on read", async () => {
104122 const cd = childDirOf(app, "body-beta");
105123 await writeFile(join(cd, "plan.json"), JSON.stringify({ name: "x", size: "small", status: "active", portalUrl: "javascript:alert(1)" }));
test/plan-wiring.test.ts +2 −1
@@ -89,7 +89,8 @@ test("a completed checkout and a Stripe sync tell the forge, with the portal lin
8989 sealKey(app.db, dir, "sk_test_abcdefgh12345678");
9090 setStripeFetch(app, (async () => new Response(JSON.stringify({ id: "sub_w", status: "past_due", customer: "cus_w", metadata: { forge: "g3", plan: "medium" }, items: { data: [{ current_period_end: 1900000000 }] } }), { status: 200 })) as unknown as typeof fetch);
9191 await syncSubscription(app, "sub_w");
92 expect(noticeOf(cd)).toMatchObject({ status: "grace", periodEnd: 1900000000 });
92 expect(noticeOf(cd)).toMatchObject({ status: "grace" });
93 expect(noticeOf(cd)!.periodEnd).toBeGreaterThan(Date.now() / 1000);
9394 });
9495
9596 test("a new portal address tells every forge; clearing it removes the link", async () => {
test/tui-plan-row.test.ts +1 −1
@@ -63,7 +63,7 @@ test("an admin reads the plan line and sets the portal; a bad address keeps noth
6363 const h = boot("alice");
6464 await h.open();
6565 expect(h.text()).toContain("Medium");
66 expect(h.text()).toContain("Past due, sleeps 2030-");
66 expect(h.text()).toContain("Payment due — paused on ");
6767 await h.press("enter");
6868 for (const ch of "http://insecure.test") await h.press(ch);
6969 await h.press("enter");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs