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

The terminal lists a forge admin's sealed secrets by name and sets or removes one, never showing a value #7245

Merged opened this

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

Merged

scout/forge:scout/7238-the-terminal-lists-a-forge-admin-s-seale → main 3 commits fork 5 threads · 5 unresolved

  • Merged. scout/7238-the-terminal-lists-a-forge-admin-s-seale was merged into main as 0a3f6fb.

Review

Sign in to review this change.
8 files changed +404 −8
docs/OPERATING.md +4
@@ -155,6 +155,10 @@ it falls back to a regex read of the served HTML). axe-core is a devDependency
155155 of this check only, never loaded by the server: its rules need a rendered page
156156 and an accessibility tree, which nothing dependency-free gives.
157157
158 ## Secrets in the terminal
159
160 A forge admin opens the palette (`ctrl+k`), picks **Secrets**, and sees the sealed names at a place with when each was set and last read — names only, never a value. `a` sets one (place, name, then the value typed or pasted masked and kept verbatim, newlines included), `d` removes the one under the cursor after a confirm, `s` looks at another place (`forge`, `org NAME`, `repo OWNER/NAME`). Everything is `actions/secrets.ts`, as the page and MCP `secrets` use it; a member has no entry.
161
158162 ## Flags
159163
160164 A forge admin sees every declared flag at `/settings/flags` (Settings → Flags): why it exists, its default, the owner issue and its live value. Set it for the whole forge, for listed user ids or roles, or for a percentage of people; Clear returns it to the default. A change is validated, kept in the change record and live on the next request, with no restart. In the terminal the same admin opens the command palette (`ctrl+k`), picks Flags, and presses enter to set a flag from a line such as `forge=on users=u1,u2 roles=admin percent=30` or `x` to clear it; an agent is never an admin there either. The MCP `flag_set` tool does the same with the same checks, for an admin token. The page and the terminal screen both list flags through one read, `readFlags` in `src/actions/flags_view.ts`.
docs/TOUR.md +2
@@ -32,6 +32,8 @@ username and password — which links that key to your account, so the next
3232 connection is instant. `j`/`k` to move, `enter` to open, `esc` to go back, `/`
3333 to filter, `?` for the keys, `:` for the command palette, `t` for the theme.
3434
35 **Secrets.** For a forge admin, `:` → *Secrets* lists sealed secret names (never values); `a` sets one, `d` removes one after a confirm, `s` changes the place. See [OPERATING](OPERATING.md#secrets-in-the-terminal).
36
3537 **Milestones.** `:` → *Milestones in a repository* → `owner/name` lists a
3638 repository's limbs in the order the crew takes ready work (`enter` opens a
3739 limb's own limbs). A person who administers the repository presses `r` to
src/tui/forge.ts +9
@@ -49,6 +49,7 @@ import { accessScreen } from "./screens/access.ts";
4949 import { mayEditTrustedHosts } from "../origins/trusted-hosts.ts";
5050 import { trustedHostsPrompt } from "./screens/trustedhosts.ts";
5151 import { flagsScreen } from "./screens/flags.ts";
52 import { secretsScreen } from "./screens/secrets.ts";
5253 import { scorecardScreen } from "./screens/scorecard.ts";
5354 import { cloudScreen } from "./screens/cloud.ts";
5455 import { crewSplitsScreen } from "./screens/crewsplits.ts";
@@ -393,6 +394,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
393394 hint: "declared flags and their live values",
394395 run: () => ctx.push(flagsScreen(session, ctx)),
395396 });
397 // Settings → Secrets, as the page has it: a forge admin; names only, never a value.

The new door needs a line in the README (or docs/TOUR.md, where the other palette screens are listed): Secrets for a forge admin, a sets, d removes, s changes the place, names only.

398 if (session.user?.isAdmin === true)
399 commands.push({
400 id: "secrets",
401 title: "Secrets",
402 hint: "sealed secrets by name; set or remove one",
403 run: () => ctx.push(secretsScreen(session, ctx)),
404 });
396405 // Settings → Origins' trusted internal hosts: the same admin gate as the page.
397406 if (mayEditTrustedHosts(session.user))
398407 commands.push({
src/tui/screens/cloud.ts +2 −2
@@ -102,7 +102,7 @@ export function cloudScreen(f: Session, ctx: Ctx): Screen {
102102 title: `${LABEL[provider as (typeof PROVIDERS)[number]]} / ${name}`,
103103 label: "the token (checked, then sealed, never shown)",
104104 mask: true,
105 onSubmit: (value) => settle(addAccount(f.app, f.user, provider, name, value), () => `Sealed ${provider}/${name}.`),
105 onSubmit: (value) => settle(addAccount(f.app, f.user, provider, name, value.trim()), () => `Sealed ${provider}/${name}.`),
106106 }),
107107 );
108108 },
@@ -160,7 +160,7 @@ export function cloudScreen(f: Session, ctx: Ctx): Screen {
160160 title: `Rotate ${provider}/${name}`,
161161 label: "the new token (checked, then sealed)",
162162 mask: true,
163 onSubmit: (value) => settle(rotateAccount(f.app, f.user, provider, name, value), () => `Rotated ${provider}/${name}.`),
163 onSubmit: (value) => settle(rotateAccount(f.app, f.user, provider, name, value.trim()), () => `Rotated ${provider}/${name}.`),
164164 }),
165165 );
166166 else if (e.key === "n")
src/tui/screens/common.ts +5 −3
@@ -452,7 +452,8 @@ export function promptScreen(
452452 /**
453453 * Show the value as dots. A terminal is the one surface where somebody
454454 * is routinely reading over your shoulder, so a secret typed into one
455 * must never be a plain string of cells.
455 * must never be a plain string of cells. A masked value is also kept
456 * verbatim: not trimmed, and a paste keeps its newlines.
456457 */
457458 mask?: boolean;
458459 /** Return an error string to keep the prompt open with a message. */
@@ -491,7 +492,8 @@ export function promptScreen(
491492 ctx.setCursor(cursor);
492493 },
493494 paste(text) {
494 value += text.replace(/\s+/g, " ");
495 // A masked value is a credential: kept as pasted (a PEM key's lines), only CR/CRLF made LF.
496 value += o.mask === true ? text.replace(/\r\n?/g, "\n") : text.replace(/\s+/g, " ");
495497 return true;
496498 },
497499 key(e) {
@@ -500,7 +502,7 @@ export function promptScreen(
500502 return true;
501503 }
502504 if (e.key === "enter") {
503 const outcome = o.onSubmit(value.trim());
505 const outcome = o.onSubmit(o.mask === true ? value : value.trim());

This no longer trims any masked prompt, and the cloud add and rotate prompts (src/tui/screens/cloud.ts:104, :162) rely on it. The page trims those values (src/web/pages/cloud.ts:179, :257), so the two doors now differ. Trim in cloud.ts's two onSubmits to match the page.

504506 if (typeof outcome === "string") {
505507 error = outcome;
506508 return true;
src/tui/screens/secrets.ts added +172
@@ -0,0 +1,172 @@
1 /**
2 * Sealed secrets in the terminal, a forge admin's: the names at a scope with
3 * when each was set and last opened, as `/settings/secrets` lists them. `s`
4 * looks at another place, `a` sets one (place, name, then the value typed
5 * masked), `d` removes the one under the cursor after a confirm. Every rule is
6 * the action's (`actions/secrets.ts`); a value is typed once, handed to it and
7 * never held, drawn or logged here. Anyone else has no command and, driving
8 * the screen directly, sees and changes nothing.
9 */
10
11 import { listSecrets, putSecret, removeSecret } from "../../actions/secrets.ts";
12 import { isSecretName, type SecretName } from "../../core/secret_store.ts";
13 import type { Ctx, Screen } from "../app/app.ts";
14 import { binding } from "../app/keys.ts";
15 import { style } from "../term/style.ts";
16 import { truncate } from "../term/width.ts";
17 import type { Session } from "../session.ts";
18 import { fill, len, split } from "../ui/layout.ts";
19 import { confirmScreen, drawList, later, ListState, promptScreen } from "./common.ts";
20
21 /** The action's refusals in this screen's words. */
22 const ERRORS: Record<string, string> = {
23 invalid_name: "A name is capital letters, digits and underscores, starting with a letter — like CLOUDFLARE_API_TOKEN.",
24 invalid_value: "A value is never empty and stays under 64 KiB.",
25 invalid_scope: "Place is forge, org NAME or repo OWNER/NAME.",
26 not_found: "No such organization or repository.",
27 no_such_secret: "No secret by that name there.",
28 };
29
30 const say = (code: string): string => ERRORS[code] ?? "That could not be done.";
31
32 interface Place {
33 readonly scope: string;
34 readonly ref: string;
35 }
36
37 /** `forge`, `org acme` or `repo alice/site` → a place; a string is the refusal. */
38 export function parsePlace(line: string): Place | string {
39 const [scope = "", ref = "", ...rest] = line.trim().split(/\s+/);
40 if (rest.length > 0 || (scope === "forge" && ref !== "")) return ERRORS.invalid_scope!;
41 if (scope === "forge") return { scope, ref: "" };
42 if ((scope === "org" || scope === "repo") && ref !== "") return { scope, ref };
43 return ERRORS.invalid_scope!;
44 }
45
46 const showPlace = (p: Place): string => (p.scope === "forge" ? "forge" : `${p.scope} ${p.ref}`);
47
48 export function secretsScreen(f: Session, ctx: Ctx): Screen {
49 const state = new ListState();
50 const allowed = f.user?.isAdmin === true;
51 let place: Place = { scope: "forge", ref: "" };
52 let note = "";
53
54 const names = (): readonly SecretName[] => {
55 const listed = allowed ? listSecrets(f.app, f.user, place.scope, place.ref) : undefined;
56 return listed?.ok ? listed.value : [];
57 };
58
59 const screen: Screen = {
60 title: "Secrets",
61 keys: [
62 binding(["j", "k"], "move cursor"),
63 binding(["a"], "set a secret"),
64 binding(["d"], "remove"),
65 binding(["s"], "other place"),
66 binding(["esc"], "back"),
67 ],
68 render(area, s) {
69 const c = ctx.theme.c;
70 const [head, list] = split(area, "v", [len(1), fill(1)]);
71 const rows = names();
72 const text = !allowed
73 ? "Secrets are kept by a forge admin."
74 : note !== ""
75 ? note
76 : rows.length === 0
77 ? `No secret at ${showPlace(place)}. a sets one.`
78 : `Sealed secrets at ${showPlace(place)}; values are never shown.`;
79 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
80 drawList(
81 s,
82 list!,
83 c,
84 state,
85 rows.map((n) => ({
86 title: n.name,
87 secondary: n.scope,
88 meta: `set ${n.updatedAt} ${n.lastReadAt === null ? "never read" : `read ${n.lastReadAt}`}`,
89 })),
90 );
91 },
92 key(e) {
93 if (state.key(e, names().length)) return true;
94 if (e.ctrl || e.alt || !allowed) return false;
95 if (e.key === "s")
96 ctx.push(
97 promptScreen(ctx, screen, {
98 title: "Secrets: other place",
99 label: "forge, org NAME or repo OWNER/NAME",
100 value: showPlace(place),
101 onSubmit: (raw) => {
102 const next = parsePlace(raw);

Validate the place here as well as parse it: run listSecrets(f.app, f.user, next.scope, next.ref) and return say(r.error) when it is not ok. Otherwise an unknown org reads as "No secret at org X".

103 if (typeof next === "string") return next;
104 const listed = listSecrets(f.app, f.user, next.scope, next.ref);
105 if (!listed.ok) return say(listed.error);
106 place = next;
107 note = "";
108 state.index = 0;
109 ctx.invalidate();
110 return undefined;
111 },
112 }),
113 );
114 else if (e.key === "a")
115 ctx.push(
116 promptScreen(ctx, screen, {
117 title: "Set a secret",
118 label: `place: forge, org NAME or repo OWNER/NAME (empty is ${showPlace(place)})`,
119 onSubmit: (raw) => {
120 const target = raw.trim() === "" ? place : parsePlace(raw);
121 if (typeof target === "string") return target;
122 return later(ctx, () =>
123 promptScreen(ctx, screen, {
124 title: "Set a secret",
outdated

Nit: check the name here (same rule as setSecret) and return the invalid_name text, so the admin doesn't type the value only to have the name refused.

125 label: "name, like CLOUDFLARE_API_TOKEN",
126 onSubmit: (name) =>
127 !isSecretName(name.trim()) ? say("invalid_name") : later(ctx, () =>
128 promptScreen(ctx, screen, {
129 title: `Set ${name.trim()}`,
130 label: "value (never shown again)",
outdated

High: value arrives here already .trim()med by promptScreen, and pasted newlines were collapsed to spaces. A multi-line or whitespace-edged credential is sealed corrupted, unlike the page, which stores it verbatim. Keep a masked value verbatim, or refuse it and point to the page, and test it with a two-line paste.

131 mask: true,
132 onSubmit: (value) => {
133 const set = putSecret(f.app, f.user, target.scope, target.ref, name.trim(), value);
134 if (!set.ok) return say(set.error);
135 place = target;
136 note = `${name.trim()} is sealed at ${showPlace(target)}.`;
137 ctx.invalidate();
138 return undefined;
139 },
140 }),
141 ),
142 }),
143 );
144 },
145 }),
146 );
147 else if (e.key === "d") {
148 const row = names()[state.index];
149 if (row === undefined) return true;
150 ctx.push(
151 confirmScreen(ctx, screen, {
152 title: `Remove ${row.name}?`,
153 body: [`It is taken from ${showPlace(place)} and cannot be read back.`],
154 actions: [
155 { label: "Remove", variant: "danger" },
156 { label: "Cancel", variant: "ghost" },
157 ],
158 selected: 1,
159 onChoose: (choice) => {
160 if (choice !== 0) return;
161 const gone = removeSecret(f.app, f.user, place.scope, place.ref, row.name);
162 note = gone.ok ? `${row.name} is removed.` : say(gone.error);
163 ctx.invalidate();
164 },
165 }),
166 );
167 } else return false;
168 return true;
169 },
170 };
171 return screen;
172 }
test/cloud-doors.test.ts +10 −3
@@ -13,7 +13,7 @@ import { createRepo } from "../src/actions/repos.ts";
1313 import { addAccount, net, renameAccount } from "../src/actions/cloud.ts";
1414 import { recordAudit } from "../src/core/cloud_audit.ts";
1515 import { getCloudAccount, listCloudAccounts } from "../src/core/cloud_account.ts";
16 import { listSecretNames, setSecret } from "../src/core/secret_store.ts";
16 import { listSecretNames, resolveSecrets, setSecret } from "../src/core/secret_store.ts";
1717 import { createToken } from "../src/core/tokens.ts";
1818 import { createUser, getUserByName, type User } from "../src/core/users.ts";
1919 import { serve, type Servers } from "../src/http/server.ts";
@@ -90,6 +90,10 @@ describe("cloud accounts at the terminal and MCP doors", () => {
9090 for (const ch of t) key(ch);
9191 await settleApp(tui);
9292 },
93 paste: async (text: string) => {
94 tui.dispatch({ type: "paste", text } as never);
95 await settleApp(tui);
96 },
9397 palette: async (q: string) => {
9498 tui.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
9599 await settleApp(tui);
@@ -109,10 +113,12 @@ describe("cloud accounts at the terminal and MCP doors", () => {
109113 await h.press("a");
110114 await h.type("github/main");
111115 await h.press("enter");
112 await h.type(T1);
116 await h.paste(`${T1}\n`); // a pasted token keeps its newline in a masked prompt; the action gets it trimmed, as from the page
113117 expect(h.text()).not.toContain(T1);
114118 await h.press("enter");
115119 expect(listCloudAccounts(app.db).map((a) => a.name)).toEqual(["main"]);
120 const sealed = (name: string) => resolveSecrets(app.db, app.config.dataDir, app.bus, [name])[name];
121 expect(sealed("CLOUD_GITHUB_MAIN")).toBe(T1);
116122 expect(listSecretNames(app.db, "forge").map((s) => s.name)).toEqual(["CLOUD_GITHUB_MAIN"]);
117123 expect(h.text()).toContain("github/main");
118124 expect(h.text()).toContain("1 call today");
@@ -134,10 +140,11 @@ describe("cloud accounts at the terminal and MCP doors", () => {
134140 await h.press("esc");
135141
136142 await h.press("r");
137 await h.type(T2);
143 await h.paste(` ${T2}\n`);
138144 expect(h.text()).not.toContain(T2);
139145 await h.press("enter");
140146 expect(h.text()).toContain("Rotated github/main");
147 expect(sealed("CLOUD_GITHUB_MAIN")).toBe(T2);
141148 expect(h.text()).not.toContain(T2);
142149
143150 await h.press("n");
test/tui-secrets.test.ts added +200
@@ -0,0 +1,200 @@
1 /**
2 * Secrets in the terminal: a forge admin sets one (the value typed masked),
3 * sees its name, removes it after a confirm; a member has no entry; and no
4 * frame ever contains the value.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App as Forge } from "../src/app.ts";
11 import { resolveSecrets } from "../src/core/secret_store.ts";
12 import { createUser, getUserByName } from "../src/core/users.ts";
13 import { createForgeApp } from "../src/tui/forge.ts";
14 import { secretsScreen } from "../src/tui/screens/secrets.ts";
15 import { Session, type TuiIdentity } from "../src/tui/session.ts";
16 import { headlessTerminal } from "../src/tui/term/terminal.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18 import { settleApp } from "./helpers/tui.ts";
19
20 quietLogs();
21
22 const VALUE = "hunter2-very-secret-value";
23
24 describe("secrets in the terminal", () => {
25 let dataDir: string;
26 let forge: Forge;
27
28 function boot(username: string) {
29 const terminal = headlessTerminal(140, 32);
30 const user = getUserByName(forge.db, username)!;
31 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
32 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
33 app.start();
34 const frames: string[] = [];
35 const key = (k: string) =>
36 app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
37 const seen = () => frames.push(app.screen.toText());
38 return {
39 text: () => app.screen.toText(),
40 frames,
41 press: async (...keys: string[]) => {
42 for (const k of keys) {
43 key(k);
44 await settleApp(app);
45 seen();
46 }
47 },
48 ctrl: async (k: string) => {
49 app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false });
50 await settleApp(app);
51 seen();
52 },
53 type: async (t: string) => {
54 for (const ch of t) {
55 key(ch);
56 seen();
57 }
58 await settleApp(app);
59 seen();
60 },
61 stop: () => app.stop(),
62 };
63 }
64
65 beforeAll(async () => {
66 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-secrets-"));
67 forge = createApp({ dataDir, port: 0 });
68 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
69 const made = await createUser(forge.db, {
70 origin: "seed",
71 trusted: true,
72 username,
73 email: `${username}@forge.local`,
74 password: "a-good-passphrase",
75 adminIfFirst,
76 });
77 if (!made.ok) throw new Error(made.error);
78 }
79 });
80 afterAll(async () => {
81 forge.close();
82 await rm(dataDir, { recursive: true, force: true });
83 });
84
85 const open = async (h: ReturnType<typeof boot>) => {
86 await h.ctrl("k");
87 await h.type("Secrets");
88 await h.press("enter");
89 };
90
91 test("an admin sets a secret, sees its name, and removes it after a confirm; no frame shows the value", async () => {
92 const h = boot("alice");
93 await open(h);
94 expect(h.text()).toContain("No secret at forge");
95
96 await h.press("a");
97 await h.press("enter"); // empty place: the one in view, forge
98 await h.type("DEMO_TOKEN");
99 await h.press("enter");
100 await h.type(VALUE);
101 await h.press("enter");
102 expect(h.text()).toContain("DEMO_TOKEN is sealed at forge.");
103 expect(h.text()).toContain("DEMO_TOKEN");
104 expect(h.text()).toContain("never read");
105
106 await h.press("d");
107 expect(h.text()).toContain("Remove DEMO_TOKEN?");
108 await h.press("right", "enter"); // Cancel
109 expect(h.text()).toContain("never read");
110 await h.press("d");
111 await h.press("left", "enter"); // Remove
112 expect(h.text()).toContain("DEMO_TOKEN is removed.");
113 expect(h.text()).not.toContain("never read");
114
115 expect(h.frames.length).toBeGreaterThan(10);
116 for (const frame of h.frames) expect(frame).not.toContain(VALUE);
117 expect(JSON.stringify(forge.db.query("SELECT * FROM event").all())).not.toContain(VALUE);
118 h.stop();
119 });
120
121 test("a refusal reads in the screen's words and sets nothing", async () => {
122 const h = boot("alice");
123 await open(h);
124 await h.press("a");
125 await h.type("org nosuchorg");
126 await h.press("enter");
127 await h.type("DEMO_TOKEN");
128 await h.press("enter");
129 await h.type(VALUE);
130 await h.press("enter");
131 expect(h.text()).toContain("No such organization or repository.");
132 await h.press("esc", "a", "enter");
133 await h.type("lower");
134 await h.press("enter");
135 expect(h.text()).toContain("A name is capital letters");
136 expect(forge.db.query("SELECT 1 FROM secret").all()).toEqual([]);
137 for (const frame of h.frames) expect(frame).not.toContain(VALUE);
138 h.stop();
139 });
140
141 test("a pasted two-line value with edge whitespace is sealed verbatim", async () => {
142 const pem = " -----BEGIN KEY-----\nabc123\n-----END KEY-----\n ";
143 const terminal = headlessTerminal(140, 32);
144 const user = getUserByName(forge.db, "alice")!;
145 const { app } = createForgeApp(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user }, { terminal, exitHooks: false });
146 app.start();
147 const key = (k: string) => app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
148 const typed = async (t: string) => {
149 for (const ch of t) key(ch);
150 await settleApp(app);
151 };
152 app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
153 await typed("Secrets");
154 key("enter");
155 await settleApp(app);
156 key("a");
157 key("enter");
158 await settleApp(app);
159 await typed("PEM_KEY");
160 key("enter");
161 await settleApp(app);
162 app.dispatch({ type: "paste", text: pem } as never);
163 await settleApp(app);
164 key("enter");
165 await settleApp(app);
166 expect(resolveSecrets(forge.db, forge.config.dataDir, forge.bus, ["PEM_KEY"])).toEqual({ PEM_KEY: pem });
167 app.stop();
168 });
169
170 test("s to an unknown org is refused, not shown as empty; a bad name is refused before the value", async () => {
171 const h = boot("alice");
172 await open(h);
173 await h.press("s");
174 await h.press("backspace", "backspace", "backspace", "backspace", "backspace");
175 await h.type("org nosuchorg");
176 await h.press("enter");
177 expect(h.text()).toContain("No such organization or repository.");
178 await h.press("esc", "a", "enter");
179 await h.type("lower");
180 await h.press("enter");
181 expect(h.text()).toContain("A name is capital letters");
182 expect(h.text()).not.toContain("never shown again");
183 h.stop();
184 });
185
186 test("a member has no entry, and driving the screen shows and changes nothing", async () => {
187 const h = boot("bob");
188 await h.ctrl("k");
189 await h.type("Secrets");
190 expect(h.text()).not.toContain("sealed secrets by name");
191 h.stop();
192
193 const user = getUserByName(forge.db, "bob")!;
194 const session = new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user });
195 const pushed: unknown[] = [];
196 const screen = secretsScreen(session, { push: (s: unknown) => pushed.push(s) } as never);
197 expect(screen.key({ key: "a", ctrl: false, alt: false, shift: false } as never)).toBe(false);
198 expect(pushed).toEqual([]);
199 });
200 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs