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

The command palette's last row is "Tell the crew: <what you typed>", and choosing it sends the tell from wherever you are #6820

Merged opened this

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

Merged

scout/forge:scout/5885-the-command-palette-s-last-row-is-tell-t → main 11 commits fork 5 threads · 5 unresolved

  • Merged. scout/5885-the-command-palette-s-last-row-is-tell-t was merged into main as aca263f.

Review

Sign in to review this change.
7 files changed +391 −10
docs/design/areas/palette.md +2
@@ -9,3 +9,5 @@ The command palette is the keyboard-first fuzzy-find overlay (`⌘K` / `Ctrl K`,
99 - `.palette-input` — the search field across the top of the dialog: full width, borderless but for a rule beneath it, 48px tall, with no focus ring of its own (the system outline under forced colours).
1010 - `.palette-list` — the scrolling results area under the input: a fixed height (not a ceiling) so the dialog does not jump as rows come and go.
1111 - `.palette-foot` — the faint one-line strip of key hints under the list, set off by a rule above it.
12
13 The palette's tell row (`data-tell`, last in the list) sends the typed sentence to `/tell`. Its answer is one `.toast[data-palette-tell]` at the top of `#main`, drawn by `tellFromHere()` in `src/web/client.ts` from existing parts only: `.toast` and `.toast-body`, a link-variant small `.btn` as the dismiss ×, and focus moved to the toast (`tabindex="-1"`, `role="status"`) because the palette that held it has just closed. It clears itself after 12 seconds. No new component class.
src/web/client.ts +87
@@ -1681,6 +1681,56 @@ export const shellJs = /* js */ `
16811681 }).catch(function () { location.href = href; });
16821682 }
16831683
1684 // The palette's tell: the feed composer's own POST (same action, same
1685 // same-origin check), with this page's path as where it was said. The
1686 // answer is one line at the top of the page, in the flash's own classes.
1687 function tellFromHere(app, text) {
1688 var main = doc.getElementById('main');
1689 function say(tone, message, href) {
1690 if (!main) return;
1691 var old = main.querySelector('.toast[data-palette-tell]');
1692 if (old) old.remove();
1693 var t = doc.createElement('div');
1694 t.className = 'toast';
1695 t.setAttribute('data-tone', tone);
1696 t.setAttribute('data-palette-tell', '');
1697 t.setAttribute('role', 'status');
1698 var b = doc.createElement('div');
1699 b.className = 'toast-body';
1700 b.appendChild(doc.createTextNode(message));
1701 if (href && href.charAt(0) === '/' && href.charAt(1) !== '/') {

Low: browsers treat /\host like //host. href comes from the server today, so this can't be exploited, but /^\/(?![\/\\])/.test(href) would make the guard say what it means.

1702 b.appendChild(doc.createTextNode(' \u00b7 '));
1703 var a = doc.createElement('a');
1704 a.setAttribute('href', href);
1705 a.textContent = 'See it';
1706 b.appendChild(a);
1707 }
1708 t.appendChild(b);
1709 var x = doc.createElement('button');
1710 x.type = 'button';
1711 x.className = 'btn';
1712 x.setAttribute('data-variant', 'link');
1713 x.setAttribute('data-size', 'sm');
1714 x.setAttribute('aria-label', 'Dismiss');
1715 x.textContent = '\u00d7';
1716 x.onclick = function () { t.remove(); };
1717 t.appendChild(x);
1718 t.tabIndex = -1;
1719 main.insertBefore(t, main.firstChild);
1720 // The palette has just closed: the answer takes the focus, then clears itself.
1721 t.focus({ preventScroll: false });
1722 window.setTimeout(function () { if (t.parentNode) t.remove(); }, 12000);
1723 }
1724 fetch('/tell', {
1725 method: 'POST', credentials: 'same-origin',
1726 headers: { accept: 'application/json' },
1727 body: new URLSearchParams({ app: app, text: text, from: location.pathname })
1728 }).then(function (r) { return r.json(); }).then(function (d) {
1729 if (d.ok) say('success', d.flash, d.href);
1730 else say('danger', d.error || '${NOT_TOLD_WORDS}');
1731 }).catch(function () { say('danger', '${NOT_TOLD_WORDS}'); });
1732 }
1733
16841734 var closePalette = null;
16851735 function openPalette() {
16861736 var dlg = doc.getElementById('palette');
@@ -1691,6 +1741,7 @@ export const shellJs = /* js */ `
16911741 var items = (loadIndex().items || []).map(function (i) {
16921742 return { t: String(i.t), h: String(i.h), g: i.c ? String(i.c) : 'Go to' };
16931743 });
1744 var tellApp = (loadIndex().tell || {}).app;
16941745 var recent = readRecent().map(function (r) { return { t: r.t, h: r.h, g: 'Recent', d: r.g }; });
16951746 var recentHrefs = recent.map(function (r) { return r.h; });
16961747 var served = []; // rows the server found, for the query servedFor
@@ -1773,6 +1824,36 @@ export const shellJs = /* js */ `
17731824 });
17741825 frag.appendChild(box);
17751826 });
1827 // Tell from anywhere: once there are words, the last row says them to the crew.
1828 if (tellApp && q.length >= 3) {
1829 var tbox = doc.createElement('div');
1830 tbox.className = 'palette-group';
1831 tbox.setAttribute('role', 'group');
1832 // A mutation is a button in a POST form, as /tell takes it without
1833 // script too; the click is caught below and sent in place.
1834 var tf = doc.createElement('form');
1835 tf.method = 'post'; tf.action = '/tell';
1836 [['app', tellApp], ['text', q], ['from', location.pathname]].forEach(function (kv) {
1837 var hid = doc.createElement('input');
1838 hid.type = 'hidden'; hid.name = kv[0]; hid.value = kv[1];
1839 tf.appendChild(hid);
1840 });
1841 var ta = doc.createElement('button');
1842 ta.type = 'submit';
1843 ta.className = 'palette-item';
1844 ta.id = 'palette-o' + nodes.length;
1845 ta.setAttribute('role', 'option');
1846 ta.setAttribute('aria-selected', 'false');
1847 ta.setAttribute('data-tell', q);
1848 var tt = doc.createElement('span');
1849 tt.className = 'grow';
1850 tt.textContent = 'Tell the crew: ' + q;
1851 ta.appendChild(tt);
1852 tf.appendChild(ta);
1853 nodes.push(ta); shown.push({ t: tt.textContent, h: '#tell', g: 'Tell' });
1854 tbox.appendChild(tf);
1855 frag.appendChild(tbox);
1856 }
17761857 if (nodes.length === 0) frag.appendChild(emptyLine(q));
17771858 list.textContent = '';
17781859 list.appendChild(frag);
@@ -1857,6 +1938,12 @@ export const shellJs = /* js */ `
18571938 var a = e.target && e.target.closest ? e.target.closest('.palette-item') : null;
18581939 var i = a ? nodes.indexOf(a) : -1;
18591940 if (i === -1) return;
1941 if (a.hasAttribute('data-tell')) {
1942 e.preventDefault();
1943 close();
1944 tellFromHere(tellApp, a.getAttribute('data-tell'));
1945 return;
1946 }
18601947 remember(shown[i]);
18611948 var h = a.getAttribute('href') || '';
18621949 if (/^\\/[^\\/?#]+\\/[^\\/?#]+\\/tell\\?from=/.test(h) && !e.metaKey && !e.ctrlKey && !e.shiftKey) {
src/web/layout.ts +32 −2
@@ -10,7 +10,8 @@
1010 import { OPENSEARCH_PATH, OPENSEARCH_TITLE, OPENSEARCH_TYPE } from "./opensearch.ts";
1111 import { protectionNotice } from "../kernel/protection.ts";
1212 import { appsForRepos } from "../core/apps.ts";
13 import { listReposVisibleTo } from "../core/repos.ts";
13 import { getRepo, listReposVisibleTo } from "../core/repos.ts";
14 import { mayDirectCrew } from "../crew/direct.ts";
1415 import type { User } from "../core/users.ts";
1516 import type { Ctx } from "../http/context.ts";
1617 import { activeEdgeToken, edgeLoginUrl, edgeLogoutUrl, edgeStrangerLogin } from "../http/edge.ts";
@@ -292,6 +293,32 @@ const STATIC_PALETTE: PaletteItem[] = [
292293 { t: "Origins", h: "/settings/origins", c: "Settings" },
293294 ];
294295
296 /** First path segments that are the forge's own pages, never an owner: no repository read for them. */
297 const NOT_AN_OWNER = new Set([
298 ...STATIC_PALETTE.map((p) => p.h.split("/")[1] ?? ""),
299 "api", "releases", "audit", "admin", "tell", "login", "logout", "signup", "feed", "boards", "organizations", "static", "assets",
300 ]);
301
302 /**
303 * Where the palette's "Tell the crew" row sends a sentence, as the feed
304 * composer's `app` field names it: the repository the page is on when the
305 * viewer may direct its crew, else the forge itself, which costs no
306 * repository read. Absent for anyone who may not tell (signed out, or an
307 * organization), and then the row is not drawn.
outdated

getRepo runs on every two-segment path. Skip reserved prefixes (settings, releases, audit...) before touching the db, so the read-budget tests stay unchanged.

308 */
309 function tellTarget(ctx: Ctx): { app: string } | undefined {
310 const user = ctx.auth.user;
311 if (user === undefined || user.kind === "org") return undefined;
312 const [owner, name] = ctx.url.pathname.split("/").filter((s) => s !== "");
313 const here =
314 owner !== undefined && name !== undefined && !NOT_AN_OWNER.has(owner)
315 ? getRepo(ctx.app.db, owner, name)
316 : undefined;
317 if (here !== undefined && mayDirectCrew(ctx.app.db, here, user))
318 return { app: `${here.ownerName}/${here.name}` };

The reserved-prefix list is a hand-kept denylist; any new top-level route silently costs a repository read. Add a test that counts getRepo calls so the gate is proven.

319 return { app: "forge" };

Medium (UX, #7124): on this fallback composerSubmit calls keepThought, so on /settings, /explore or home the row's "Tell the crew" label promises something the action doesn't do, and the from path is lost. Either drop the row here or relabel it, for example "Keep as a thought: …".

320 }
321
295322 /**
296323 * The palette index rides as a `<script type="application/json">` data block.
297324 * The HTML spec stops parsing such a block as script, so the CSP never
@@ -338,7 +365,7 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html {
338365 // API found, folded in the browser — build the same addresses the server
339366 // just rendered (`tellHref`/`changeHref`, `src/web/studio/nouns.ts`). The
340367 // palette can never hand out a noun the page behind it would not.
341 const json = JSON.stringify({ items, studio }).replaceAll("<", "\\u003c");
368 const json = JSON.stringify({ items, studio, tell: tellTarget(ctx) }).replaceAll("<", "\\u003c");
342369 return raw(
343370 `<script type="application/json" id="palette-index">${json}</script>`,
344371 );
@@ -491,6 +518,9 @@ function footer(ctx: Ctx): Html {
491518 >${reduced}</span
492519 >`}
493520 <span class="spacer"></span>
521 ${ctx.auth.user !== undefined && ctx.auth.user.kind !== "org"
522 ? html`<a class="btn" data-variant="link" data-size="sm" href="/#composer">Tell the crew</a>`
523 : ""}
494524 <button
495525 class="btn js-only"
496526 data-variant="link"
src/web/studio/actions.ts +22 −7
@@ -81,14 +81,26 @@ async function tellFrom(
8181 // A client's "Ask for something" (named by the form, not guessed from `/`):
8282 // they asked for something and want nothing more than to know it went.
8383 if (from === "/" && field(form, "ask") === "1") return flashOrJson(ctx, back, { message: SENT_WORDS });
84 // The tell's own page, for a caller that asked for JSON (the palette's row).
85 const at = { href: `${repoBase(repo)}/issues/${told.value.issue.number}` };
8486 if (routed === undefined)
85 return flashOrJson(ctx, back, {
86 message: `Opened as an issue. Nobody is on the crew to take it yet — add someone under Settings → Crew.`,
87 });
87 return flashOrJson(
88 ctx,
89 back,
90 {
91 message: `Opened as an issue. Nobody is on the crew to take it yet — add someone under Settings → Crew.`,
92 },
93 at,
94 );
8895 const builder = routed.builder;
89 return flashOrJson(ctx, back, {
90 message: `${builder.user.username} is on it — ${routed.size} ${routed.kind}${routed.by === "rule" ? "" : `, ${routed.by} decided in ${routed.ms} ms`}.${from === null ? " Watch it work below." : ""}`,
91 });
96 return flashOrJson(
97 ctx,
98 back,
99 {
100 message: `${builder.user.username} is on it — ${routed.size} ${routed.kind}${routed.by === "rule" ? "" : `, ${routed.by} decided in ${routed.ms} ms`}.${from === null ? " Watch it work below." : ""}`,
101 },
102 at,
103 );
92104 }
93105
94106 /** POST /:owner/:name/tell { text } — a sentence becomes work. */
@@ -160,8 +172,10 @@ export async function composerSubmit(ctx: Ctx): Promise<Response> {
160172 const target = field(form, "app");
161173 // "the forge" is no app: the words are kept as a thought, exactly as typed.
162174 if (target === FORGE_TARGET) {
175 if (field(form, "text").trim() === "")
176 return flashOrJson(ctx, "/", { tone: "danger", message: SAY_SOMETHING_WORDS });
163177 const kept = keepThought(ctx.app, ctx.auth.user, field(form, "text"), "composer");
164 return flashTo(
178 return flashOrJson(
165179 ctx,
166180 "/",
167181 kept.ok
@@ -171,6 +185,7 @@ export async function composerSubmit(ctx: Ctx): Promise<Response> {
171185 message:
172186 kept.error === "too_long" ? "That is too long to keep." : "That could not be kept.",
173187 },
188 { href: "/" },
174189 );
175190 }
176191 const [owner = "", name = "", ...extra] = target.split("/");
test/helpers/chrome.ts +2 −1
@@ -107,7 +107,7 @@ async function readDebuggerUrl(
107107 * cookies and the given colour scheme, and return what `expression` (a JS
108108 * expression that yields a JSON string) says once the page has loaded.
109109 */
110 async function evaluateLoaded(
110 export async function evaluateLoaded(
111111 binary: string,
112112 url: string,
113113 viewport: { width: number; height: number },
@@ -187,6 +187,7 @@ async function evaluateLoaded(
187187 const { result } = await cdp.send("Runtime.evaluate", {
188188 expression,
189189 returnByValue: true,
190 awaitPromise: true,
190191 });
191192 return result.value as string;
192193 } finally {
test/palette-tell-gate.test.ts added +74
@@ -0,0 +1,74 @@
1 /**
2 * The palette's tell target costs a repository read only where the first path
3 * segment can be an owner (#5885): the forge's own pages never pay it, so a
4 * new top-level route that is left off the list shows up here as a read.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { forgeHttp, type ForgeHttp } from "../src/http/server.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 describe("the tell target's repository read", () => {
20 let dataDir: string;
21 let app: App;
22 let cookie = "";
23 let reads = 0;
24 let http: ForgeHttp;
25
26 beforeAll(async () => {
27 dataDir = await mkdtemp(join(tmpdir(), "forge-palette-gate-"));
28 app = createApp({ dataDir, port: 0 });
29 const made = await createUser(app.db, {
30 origin: "seed",
31 trusted: true,
32 username: "keeper",
33 email: "[email protected]",
34 password: "a-good-passphrase",
35 adminIfFirst: true,
36 });
37 if (!made.ok) throw new Error(made.error);
38 http = forgeHttp(app);
39 cookie = `forge_session=${createSession(app.db, made.value.id).token}`;
40 // Count the by-name repository lookup, the one `getRepo` issues.
41 const query = app.db.query.bind(app.db) as (sql: string) => unknown;
42 (app.db as { query: unknown }).query = (sql: string) => {
43 if (sql.includes("user.username = ?1 COLLATE NOCASE AND repo.name = ?2")) reads++;
44 return query(sql);
45 };
46 });
47
48 afterAll(async () => {
49 app.close();
50 await rm(dataDir, { recursive: true, force: true });
51 });
52
53 const get = async (path: string): Promise<string> => {
54 reads = 0;
55 const res = await http.handle(new Request(`http://localhost${path}`, { headers: { cookie } }));
56 return res.text();
57 };
58
59 test("the forge's own two-segment pages read no repository for the target", async () => {
60 for (const path of ["/settings/origins", "/settings/tokens", "/settings/keys"]) {
61 const html = await get(path);
62 expect(html).toContain(`"tell":{"app":"forge"}`);
63 expect(reads).toBe(0);
64 }
65 });
66
67 test("a path that can be an owner and a name costs the target one read, which signed out it does not", async () => {
68 await get("/nobody/nothing");
69 const signedIn = reads;
70 reads = 0;
71 await http.handle(new Request("http://localhost/nobody/nothing"));
72 expect(signedIn).toBe(reads + 1);
73 });
74 });
test/palette-tell.test.ts added +172
@@ -0,0 +1,172 @@
1 /**
2 * The palette's last row, "Tell the crew: <what you typed>" (#5885): in a
3 * real headless Chromium on a repository page, type a sentence, pick the
4 * row, and the tell is in the forge with that page as where it was said.
5 * Signed out, the row is not drawn. The refusals are the action's own words.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { createSession } from "../src/core/sessions.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { findChromium } from "../src/looks/capture.ts";
18 import { chromiumRuns, evaluateLoaded } from "./helpers/chrome.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20 import { promote } from "./helpers/members.ts";
21
22 quietLogs();
23
24 const found = findChromium();
25 const chromium = found !== undefined && chromiumRuns(found) ? found : undefined;
26 if (chromium === undefined)
27 console.log("SKIPPING: no chromium that starts on this machine, so the palette tell browser tests cannot run");
28
29 /** Open the palette, type `text`, and report the rows; with `pick`, choose the tell row. */
30 const drive = (text: string, pick: boolean): string => `(async () => { try {
31 const wait = (ms) => new Promise((r) => setTimeout(r, ms));
32 const opener = document.querySelector("[data-palette]");
33 if (!opener) return JSON.stringify({ error: "no palette button on " + location.href + ": " + document.title });
34 opener.click();
35 await wait(100);
36 const dlg = document.getElementById("palette");
37 const input = dlg.querySelector(".palette-input");
38 input.value = ${JSON.stringify(text)};
39 input.dispatchEvent(new Event("input"));
40 await wait(100);
41 const rows = [...dlg.querySelectorAll(".palette-item")];
42 const last = rows[rows.length - 1];
43 const tellRow = last && last.hasAttribute("data-tell") ? last.textContent : null;
44 let toast = null;
45 if (${pick} && last) {
46 last.click();
47 await wait(800);
48 const t = document.querySelector(".toast[data-palette-tell]");
49 toast = t ? { text: t.querySelector(".toast-body").textContent, link: t.querySelector("a")?.getAttribute("href") ?? null } : null;
50 }
51 return JSON.stringify({ tellRow, rows: rows.length, open: dlg.open, toast });
52 } catch (e) { return JSON.stringify({ error: String(e && e.stack || e) }); }
53 })()`;
54
55 describe.skipIf(chromium === undefined)("the palette's tell row", () => {
56 let dataDir: string;
57 let app: App;
58 let servers: Servers;
59 let cookies: { name: string; value: string }[] = [];
60
61 beforeAll(async () => {
62 dataDir = await mkdtemp(join(tmpdir(), "forge-palette-tell-"));
63 app = createApp({ dataDir, port: 0 });
64 servers = await serve(app);
65 const made = await createUser(app.db, {
66 origin: "seed",
67 trusted: true,
68 username: "keeper",
69 email: "[email protected]",
70 password: "a-good-passphrase",
71 adminIfFirst: true,
72 });
73 if (!made.ok) throw new Error(made.error);
74 promote(app.db, made.value);
75 cookies = [{ name: "forge_session", value: createSession(app.db, made.value.id).token }];
76 const repo = await createRepo(app, made.value, { name: "garden", visibility: "public" });
77 if (!repo.ok) throw new Error("repo");
78 const seeded = await commitToBranch(app, made.value, repo.value, {
79 message: "seed",
80 files: [{ path: "README.md", content: "# garden\n" }],
81 });
82 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
83 }, 30_000);
84
85 afterAll(async () => {
86 servers.stop();
87 app.close();
88 await rm(dataDir, { recursive: true, force: true });
89 });
90
91 test("on a repository page, typing a sentence and picking the last row tells the crew from that page", async () => {
92 const said = "make the garden gate green";
93 const out = JSON.parse(
94 await evaluateLoaded(
95 chromium!,
96 `${servers.url}/keeper/garden`,
97 { width: 1280, height: 800 },
98 cookies,
99 drive(said, true),
100 ),
101 );
102 expect(out.error).toBeUndefined();
103 expect(out.tellRow).toBe(`Tell the crew: ${said}`);
104 expect(out.open).toBe(false);
105 expect(out.toast.link).toMatch(/^\/keeper\/garden\/issues\/\d+$/);
106 const issue = app.db
107 .query("SELECT title, body FROM issue WHERE title LIKE ?1")
108 .get(`%${said}%`) as { title: string; body: string } | null;
109 expect(issue).not.toBeNull();
110 expect(issue!.body).toContain("Told from [/keeper/garden](/keeper/garden)");
111 }, 30_000);
112
113 test("under three characters there is no tell row", async () => {
114 const out = JSON.parse(
115 await evaluateLoaded(chromium!, `${servers.url}/keeper/garden`, { width: 1280, height: 800 }, cookies, drive("ga", false)),
116 );
117 expect(out.error).toBeUndefined();
118 expect(out.tellRow).toBeNull();
119 }, 30_000);
120
121 test("signed out, the row is not drawn and the page carries no tell target", async () => {
122 const out = JSON.parse(
123 await evaluateLoaded(chromium!, `${servers.url}/keeper/garden`, { width: 1280, height: 800 }, [], drive("make the garden gate green", false)),
124 );
125 expect(out.error).toBeUndefined();
126 expect(out.tellRow).toBeNull();
127 const html = await (await fetch(`${servers.url}/keeper/garden`)).text();
128 expect(html).not.toContain('"tell":');
129 }, 30_000);
130 });
131
132 describe("the tell the palette posts", () => {
133 test("an empty tell answers in the action's own words, as JSON", async () => {
134 const dataDir = await mkdtemp(join(tmpdir(), "forge-palette-tell-"));
135 const app = createApp({ dataDir, port: 0 });
136 const servers = await serve(app);
137 try {
138 const made = await createUser(app.db, {
139 origin: "seed",
140 trusted: true,
141 username: "keeper",
142 email: "[email protected]",
143 password: "a-good-passphrase",
144 adminIfFirst: true,
145 });
146 if (!made.ok) throw new Error(made.error);
147 promote(app.db, made.value);
148 const cookie = `forge_session=${createSession(app.db, made.value.id).token}`;
149 const res = await fetch(`${servers.url}/tell`, {
150 method: "POST",
151 headers: { cookie, accept: "application/json" },
152 body: new URLSearchParams({ app: "forge", text: " ", from: "/" }),
153 });
154 expect(res.status).toBe(422);
155 const body = (await res.json()) as { ok: boolean; error: string };
156 expect(body.ok).toBe(false);
157 expect(body.error.length).toBeGreaterThan(0);
158 const index = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text();
159 expect(index).toContain('"tell":{"app":"forge"}');
160 // Without script there is still a door to the composer, signed in only.
161 expect(index).toContain('href="/#composer"');
162 expect(await (await fetch(`${servers.url}/explore`)).text()).not.toContain('href="/#composer"');
163 // A forge page is not an owner's: it still answers with the forge as the target.
164 const settings = await (await fetch(`${servers.url}/settings/origins`, { headers: { cookie } })).text();
165 expect(settings).toContain('"tell":{"app":"forge"}');
166 } finally {
167 servers.stop();
168 app.close();
169 await rm(dataDir, { recursive: true, force: true });
170 }
171 }, 30_000);
172 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs