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

The feed composer keeps what you typed across a reload, and clears it once the tell is sent #6686

Merged opened this

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

Merged

scout/forge:scout/5901-the-feed-composer-keeps-what-you-typed-a → main 6 commits fork

  • Merged. scout/5901-the-feed-composer-keeps-what-you-typed-a was merged into main as 8af2af6.

Review

Sign in to review this change.
10 files changed +410 −97
README.md +1 −1
@@ -688,7 +688,7 @@ This page is the front door. Everything else is a file of its own under
688688 - [`docs/STUDIO.md`](docs/STUDIO.md) — the front door in full: the five words and where each is decided, the box, the three lists, a tell, a run and a change each on its own page, and the board.
689689 - [`docs/TOUR.md`](docs/TOUR.md) — every other page and screen, with each of its doors: the terminal over SSH, the home and its figures, review, signing in, the page-view beacon, the board's notes, long threads, who can see a repository, archive, where a repository came from, big directories, search, sharing to the forge, link-preview cards and choosing among variants.
690690 - [`docs/ASKS.md`](docs/ASKS.md) — what waits on you and how to answer it: waiting pulls, mutes, wave proposals, feed chips and the decider.
691 - [`docs/FEED.md`](docs/FEED.md) — the feed: what happened, ranked, each item with why it is there.
691 - [`docs/FEED.md`](docs/FEED.md) — the feed: what happened, ranked, each item with why it is there. The home composer keeps your draft across a reload.
692692 - [`docs/PULLS.md`](docs/PULLS.md) — resolving a conflicted pull without a clone.
693693 - [`docs/GIT.md`](docs/GIT.md) — cloning over HTTPS and SSH, and partial clones of big repositories.
694694 - [`docs/ROLES.md`](docs/ROLES.md) — guest, member and admin, and what each may do.
docs/FEED.md +10
@@ -142,3 +142,13 @@ one `POST /api/v1/asks/bulk` (`action`, `items`, `reason`, `preview: false`);
142142 still names one). The list swaps in place and the top bar's count follows. One undo link
143143 (`POST /api/v1/asks/bulk/<id>/undo`) is kept for a day. The MCP's `asks_act`
144144 takes the same set, verb and reason.
145
146 ## The composer keeps your draft
147
148 What you type in the home composer is saved in the browser's localStorage
149 (per signed-in user and page, at most once a second) and is back after a
150 reload. Sending clears it; a send the forge refuses puts the words back and
151 keeps them; signing out clears every draft. It lives in the feed's existing
152 script (no new script, no dependency). Not covered: the "the forge" thought
153 target posts as a plain form, so its draft is dropped on submit, and the
154 lean client home has no feed script, so no draft.
src/release/axe.ts +1 −85
@@ -14,14 +14,11 @@
1414 * roles) need a rendered page and a computed accessibility tree.
1515 */
1616
17 import { mkdtempSync, rmSync } from "node:fs";
18 import { tmpdir } from "node:os";
19 import { join } from "node:path";
2017 import { findChromium } from "../looks/capture.ts";
18 import { withPage } from "./devtools.ts";
2119 import { s, type Finding } from "./webspec.ts";
2220
2321 const SCHEMES = ["light", "dark"] as const;
24 const STEP_MS = 15000;
2522
2623 export interface AxeViolation {
2724 id: string;
@@ -69,87 +66,6 @@ export async function noScript(base: string, paths: string[], fetcher: typeof fe
6966 return findings;
7067 }
7168
72 /** A DevTools connection to one page of a private headless Chromium. */
73 async function withPage<T>(binary: string, use: (send: (method: string, params?: object) => Promise<any>, once: (event: string) => Promise<void>) => Promise<T>): Promise<T> {
74 const profile = mkdtempSync(join(tmpdir(), "forge-axe-"));
75 const proc = Bun.spawn(
76 [binary, "--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--disable-background-networking", "--no-first-run", `--user-data-dir=${profile}`, "--remote-debugging-port=0", "about:blank"],
77 { stdin: "ignore", stdout: "ignore", stderr: "pipe" },
78 );
79 try {
80 const reader = proc.stderr.getReader();
81 const decoder = new TextDecoder();
82 let said = "";
83 let endpoint: string | undefined;
84 const deadline = Date.now() + STEP_MS;
85 while (!endpoint && Date.now() < deadline) {
86 const { done, value } = await Promise.race([reader.read(), Bun.sleep(STEP_MS).then(() => ({ done: true, value: undefined }))]);
87 if (done) break;
88 said += decoder.decode(value, { stream: true });
89 endpoint = /DevTools listening on (ws:\/\/\S+)/.exec(said)?.[1];
90 }
91 if (!endpoint) throw new Error("chromium did not offer a DevTools endpoint");
92 // The rest of what it says is read and dropped, so the pipe never fills.
93 (async () => {
94 while (!(await reader.read()).done);
95 })().catch(() => {});
96 const ws = new WebSocket(endpoint);
97 await new Promise<void>((resolve, reject) => {
98 ws.onopen = () => resolve();
99 ws.onerror = () => reject(new Error("could not reach DevTools"));
100 });
101 let id = 0;
102 let session: string | undefined;
103 const pending = new Map<number, { ok: (v: any) => void; no: (e: Error) => void }>();
104 const waiting = new Map<string, (() => void)[]>();
105 ws.onmessage = (m) => {
106 const msg = JSON.parse(String(m.data));
107 if (msg.id !== undefined) {
108 const p = pending.get(msg.id);
109 pending.delete(msg.id);
110 if (msg.error) p?.no(new Error(msg.error.message));
111 else p?.ok(msg.result);
112 } else if (msg.method) for (const f of [...(waiting.get(msg.method) ?? [])]) f();
113 };
114 const send = (method: string, params: object = {}) =>
115 new Promise<any>((ok, no) => {
116 const n = ++id;
117 const timer = setTimeout(() => {
118 if (pending.delete(n)) no(new Error(`${method} timed out`));
119 }, STEP_MS);
120 pending.set(n, {
121 ok: (v) => (clearTimeout(timer), ok(v)),
122 no: (e) => (clearTimeout(timer), no(e)),
123 });
124 ws.send(JSON.stringify({ id: n, method, params, ...(session ? { sessionId: session } : {}) }));
125 });
126 const once = (event: string) =>
127 new Promise<void>((ok, no) => {
128 const done = () => {
129 clearTimeout(timer);
130 waiting.set(event, (waiting.get(event) ?? []).filter((f) => f !== done));
131 ok();
132 };
133 const timer = setTimeout(() => {
134 waiting.set(event, (waiting.get(event) ?? []).filter((f) => f !== done));
135 no(new Error(`${event} timed out`));
136 }, STEP_MS);
137 waiting.set(event, [...(waiting.get(event) ?? []), done]);
138 });
139 const { targetId } = await send("Target.createTarget", { url: "about:blank" });
140 session = (await send("Target.attachToTarget", { targetId, flatten: true })).sessionId;
141 try {
142 return await use(send, once);
143 } finally {
144 ws.close();
145 }
146 } finally {
147 proc.kill("SIGKILL");
148 await proc.exited.catch(() => {});
149 rmSync(profile, { recursive: true, force: true });
150 }
151 }
152
15369 /** Runs axe-core over each path in each colour scheme, once each, and reports its violations. */
15470 export async function axe(base: string, paths: string[]): Promise<Finding[]> {
15571 const binary = findChromium();
src/release/devtools.ts added +91
@@ -0,0 +1,91 @@
1 /**
2 * A DevTools connection to one page of a private headless Chromium, shared
3 * by the accessibility check and the browser tests.
4 */
5
6 import { mkdtempSync, rmSync } from "node:fs";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9
10 const STEP_MS = 15000;
11
12 /** A DevTools connection to one page of a private headless Chromium. */
13 export async function withPage<T>(binary: string, use: (send: (method: string, params?: object) => Promise<any>, once: (event: string) => Promise<void>) => Promise<T>): Promise<T> {
14 const profile = mkdtempSync(join(tmpdir(), "forge-axe-"));
15 const proc = Bun.spawn(
16 [binary, "--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--disable-background-networking", "--no-first-run", `--user-data-dir=${profile}`, "--remote-debugging-port=0", "about:blank"],
17 { stdin: "ignore", stdout: "ignore", stderr: "pipe" },
18 );
19 try {
20 const reader = proc.stderr.getReader();
21 const decoder = new TextDecoder();
22 let said = "";
23 let endpoint: string | undefined;
24 const deadline = Date.now() + STEP_MS;
25 while (!endpoint && Date.now() < deadline) {
26 const { done, value } = await Promise.race([reader.read(), Bun.sleep(STEP_MS).then(() => ({ done: true, value: undefined }))]);
27 if (done) break;
28 said += decoder.decode(value, { stream: true });
29 endpoint = /DevTools listening on (ws:\/\/\S+)/.exec(said)?.[1];
30 }
31 if (!endpoint) throw new Error("chromium did not offer a DevTools endpoint");
32 // The rest of what it says is read and dropped, so the pipe never fills.
33 (async () => {
34 while (!(await reader.read()).done);
35 })().catch(() => {});
36 const ws = new WebSocket(endpoint);
37 await new Promise<void>((resolve, reject) => {
38 ws.onopen = () => resolve();
39 ws.onerror = () => reject(new Error("could not reach DevTools"));
40 });
41 let id = 0;
42 let session: string | undefined;
43 const pending = new Map<number, { ok: (v: any) => void; no: (e: Error) => void }>();
44 const waiting = new Map<string, (() => void)[]>();
45 ws.onmessage = (m) => {
46 const msg = JSON.parse(String(m.data));
47 if (msg.id !== undefined) {
48 const p = pending.get(msg.id);
49 pending.delete(msg.id);
50 if (msg.error) p?.no(new Error(msg.error.message));
51 else p?.ok(msg.result);
52 } else if (msg.method) for (const f of [...(waiting.get(msg.method) ?? [])]) f();
53 };
54 const send = (method: string, params: object = {}) =>
55 new Promise<any>((ok, no) => {
56 const n = ++id;
57 const timer = setTimeout(() => {
58 if (pending.delete(n)) no(new Error(`${method} timed out`));
59 }, STEP_MS);
60 pending.set(n, {
61 ok: (v) => (clearTimeout(timer), ok(v)),
62 no: (e) => (clearTimeout(timer), no(e)),
63 });
64 ws.send(JSON.stringify({ id: n, method, params, ...(session ? { sessionId: session } : {}) }));
65 });
66 const once = (event: string) =>
67 new Promise<void>((ok, no) => {
68 const done = () => {
69 clearTimeout(timer);
70 waiting.set(event, (waiting.get(event) ?? []).filter((f) => f !== done));
71 ok();
72 };
73 const timer = setTimeout(() => {
74 waiting.set(event, (waiting.get(event) ?? []).filter((f) => f !== done));
75 no(new Error(`${event} timed out`));
76 }, STEP_MS);
77 waiting.set(event, [...(waiting.get(event) ?? []), done]);
78 });
79 const { targetId } = await send("Target.createTarget", { url: "about:blank" });
80 session = (await send("Target.attachToTarget", { targetId, flatten: true })).sessionId;
81 try {
82 return await use(send, once);
83 } finally {
84 ws.close();
85 }
86 } finally {
87 proc.kill("SIGKILL");
88 await proc.exited.catch(() => {});
89 rmSync(profile, { recursive: true, force: true });
90 }
91 }
src/web/draft.ts added +64
@@ -0,0 +1,64 @@
1 /**
2 * The draft: what a person has typed into the feed composer, kept in the
3 * browser so a reload or a closed tab does not lose it (#5913). Saved a
4 * second after the last keystroke, put back only into an empty box, dropped
5 * once the tell is sent, and forgotten at sign-out.
6 *
7 * Shipped to the page as source text (like the outbox) and evaluated by the
8 * test with a fake storage and timer, so the code that runs is the code
9 * that is tested.
10 */
11
12 export const DRAFT_PREFIX = "draft.";
13 export const DRAFT_DELAY_MS = 1000;
14
15 export const draftJs = /* js */ `
16 function forgeDraft(storage, box, key, later) {
17 var PREFIX = ${JSON.stringify(DRAFT_PREFIX)};
18 var DELAY = ${DRAFT_DELAY_MS};
19 var pending = false;
20 var round = 0;
21 function save() {
22 pending = false;
23 try {
24 if (box.value) storage.setItem(key, box.value);
25 else storage.removeItem(key);
26 } catch (e) {}
27 }
28 return {
29 // Put the saved words back, unless the person has already typed.
30 restore: function () {
31 if (box.value) return;
32 try { box.value = storage.getItem(key) || ''; } catch (e) {}
33 },
34 // One timer per burst of typing, not one per keystroke.
35 typed: function () {
36 if (pending) return;
37 pending = true;
38 var mine = round;
39 later(function () { if (mine === round) save(); }, DELAY);
40 },
41 // A refused send: give the words back unless new ones have been typed.
42 refused: function (text) {
43 if (!box.value) box.value = text;
44 save();
45 },
46 // The words are sent: drop the saved copy and any save still waiting.
47 clear: function () {
48 round++;
49 pending = false;
50 try { storage.removeItem(key); } catch (e) {}
51 },
52 // Sign-out: every saved draft of this browser, whoever's it was.
53 forgetAll: function () {
54 try {
55 var keys = [];
56 for (var i = 0; i < storage.length; i++) keys.push(storage.key(i));
57 keys.forEach(function (name) {
58 if (name.startsWith(PREFIX)) storage.removeItem(name);
59 });
60 } catch (e) {}
61 }
62 };
63 }
64 `;
src/web/outbox.ts +3 −3
@@ -49,7 +49,7 @@ function forgeOutbox(storage, send, now) {
4949 function run() {
5050 var list = read();
5151 if (list.length === 0) return Promise.resolve({ sent: 0, left: 0 });
52 var sent = 0;
52 var sent = 0, refused = 0;
5353 function next() {
5454 var entry = read()[0];
5555 if (!entry) return Promise.resolve();
@@ -60,11 +60,11 @@ function forgeOutbox(storage, send, now) {
6060 }).then(function (r) {
6161 if (!final(r.status)) return;
6262 write(read().filter(function (e) { return e.key !== entry.key; }));
63 sent++;
63 if (r.status < 300) sent++; else refused++;
6464 return next();
6565 }, function () {});
6666 }
67 return next().then(function () { return { sent: sent, left: read().length }; });
67 return next().then(function () { return { sent: sent, refused: refused, left: read().length }; });
6868 }
6969 // One flush at a time: two tabs of one page share storage, two flushes in
7070 // one page must not send an entry twice before the first has been dropped.
src/web/studio/feed.ts +17 −2
@@ -24,6 +24,7 @@ import { mayFetchLook, pictured } from "../pages/looks.ts";
2424 import { dividers } from "./day_divider.ts";
2525 import { html, type Html } from "../html.ts";
2626 import { fragment } from "../layout.ts";
27 import { draftJs } from "../draft.ts";
2728 import { outboxJs } from "../outbox.ts";
2829 import { avatar, btn, card, disclosure, empty, nameMark, stateBadge, time, timeAgo } from "../ui.ts";
2930 import { writableAppsOf } from "./asks.ts";
@@ -221,7 +222,7 @@ export function composer(ctx: Ctx, user: User): Html {
221222 })}`;
222223 const form = html`<form id="composer" class="card" method="post" action="/tell">
223224 <div class="card-body stack">
224 <textarea class="input" name="text" rows="2" maxlength="2000" required placeholder="Tell the crew what should change" aria-label="Tell the crew">${sharedDraft(ctx.url.searchParams)}</textarea>
225 <textarea class="input" name="text" rows="2" maxlength="2000" required placeholder="Tell the crew what should change" aria-label="Tell the crew" data-draft="${user.id}">${sharedDraft(ctx.url.searchParams)}</textarea>
225226 <div class="bar">
226227 ${repos.length === 0
227228 ? html`<input type="hidden" name="app" value="${FORGE_TARGET}" /><span class="muted small">the forge</span>`
@@ -419,6 +420,7 @@ function keepDay(card) {
419420 */
420421 export const feedJs = /* js */ `
421422 ${outboxJs}
423 ${draftJs}
422424 ${dayKeepJs}
423425 (function () {
424426 var feed = document.getElementById('live-feed');
@@ -545,15 +547,27 @@ ${dayKeepJs}
545547 // The composer is a tell too: kept and sent the same way; the box clears
546548 // at once, and the post arrives in the timeline when the forge says so.
547549 var composer = document.getElementById('composer');
550 var draft = null;
551 if (composer && composer.elements.text && outbox) {
552 var draftBox = composer.elements.text;
553 draft = forgeDraft(window.localStorage, draftBox, 'draft.' + draftBox.dataset.draft + location.pathname, function (fn, ms) { setTimeout(fn, ms); });
554 draft.restore();
555 draftBox.addEventListener('input', draft.typed);
556 }
548557 if (composer) composer.addEventListener('submit', function (e) {
549558 var box = composer.elements.text;
550559 var app = composer.elements.app;
551560 var text = box ? box.value.trim() : '';
552 if (!text || !app || app.value === '${FORGE_TARGET}') return;
561 if (!text || !app) return;
562 // Sent from here, or posted natively (the forge target): either way the
563 // words are gone from the box, so the saved copy goes too.
564 if (draft) draft.clear();
565 if (app.value === '${FORGE_TARGET}') return;
553566 if (!queue('/api/v1/feed/tells', { app: app.value, text: text })) return;
554567 e.preventDefault();
555568 box.value = '';
556569 box.focus();
570 outbox.flush().then(function (r) { if (r.refused && draft) draft.refused(text); });
557571 });
558572 document.addEventListener('keydown', function (e) {
559573 if (e.key !== 'x' || e.metaKey || e.ctrlKey || e.altKey) return;
@@ -571,6 +585,7 @@ ${dayKeepJs}
571585 document.addEventListener('submit', function (e) {
572586 if (e.target && e.target.getAttribute && e.target.getAttribute('action') === '/logout') {
573587 if (outbox) outbox.clear();
588 if (draft) draft.forgetAll();
574589 if (navigator.serviceWorker && navigator.serviceWorker.controller) navigator.serviceWorker.controller.postMessage('clear');
575590 }
576591 });
test/composer-draft.test.ts added +217
@@ -0,0 +1,217 @@
1 /**
2 * The composer keeps what you typed (#5913): saved per user and page, put
3 * back on load, dropped once the tell is sent, kept when it is refused, and
4 * gone at sign-out. The browser test drives a real Chromium through
5 * type, reload, send, reload.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtemp } 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 { createRepo } from "../src/actions/repos.ts";
13 import { createApp as insertApp } from "../src/core/apps.ts";
14 import { createSession } from "../src/core/sessions.ts";
15 import { createUser, type User } 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 { withPage } from "../src/release/devtools.ts";
19 import { draftJs } from "../src/web/draft.ts";
20 import { feedJs } from "../src/web/studio/feed.ts";
21 import { promote } from "./helpers/members.ts";
22
23 let app: App;
24 let servers: Servers;
25 let alice: User;
26 let cookie: string;
27
28 beforeAll(async () => {
29 app = createApp({ dataDir: await mkdtemp(join(tmpdir(), "draft.")), port: 0 });
30 servers = await serve(app);
31 const mk = async (username: string, first = false) => {
32 const r = await createUser(app.db, {
33 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
34 password: "a-good-passphrase", adminIfFirst: first,
35 });
36 if (!r.ok) throw new Error(r.error);
37 promote(app.db, r.value);
38 return r.value;
39 };
40 await mk("keeper", true);
41 alice = await mk("alice");
42 cookie = createSession(app.db, alice.id).token;
43 const g = await createRepo(app, alice, { name: "garden", visibility: "public" });
44 if (!g.ok) throw new Error("repo");
45 insertApp(app.db, { repoId: g.value.id, slug: "garden", access: "public" });
46 });
47 afterAll(() => servers.stop());
48
49 test("the composer names its user for the draft key, and the script stays small", async () => {
50 const res = await fetch(`${servers.url}/`, { headers: { cookie: `forge_session=${cookie}; forge_mode=studio` } });
51 const page = await res.text();
52 expect(page).toContain(`data-draft="${alice.id}"`);
53 expect(feedJs).toContain("forgeDraft(");
54 });
55
56 type Draft = {
57 restore(): void; typed(): void; refused(text: string): void; clear(): void; forgetAll(): void;
58 };
59 const makeDraft = new Function(`${draftJs}; return forgeDraft;`)() as (
60 storage: Storage, box: { value: string }, key: string, later: (fn: () => void, ms: number) => void,
61 ) => Draft;
62 const memory = (): Storage => {
63 const m = new Map<string, string>();
64 return {
65 getItem: (k: string) => m.get(k) ?? null,
66 setItem: (k: string, v: string) => void m.set(k, v),
67 removeItem: (k: string) => void m.delete(k),
68 key: (i: number) => [...m.keys()][i] ?? null,
69 get length() { return m.size; },
70 } as Storage;
71 };
72 const rig = () => {
73 const store = memory();
74 const box = { value: "" };
75 const timers: Array<() => void> = [];
76 const draft = makeDraft(store, box, "draft.u1/", (fn, ms) => { expect(ms).toBe(1000); timers.push(fn); });
77 return { store, box, timers, draft };
78 };
79
80 test("typing is saved once per burst, after the delay", () => {
81 const { store, box, timers, draft } = rig();
82 box.value = "Wa"; draft.typed();
83 box.value = "Water"; draft.typed();
84 expect(timers.length).toBe(1);
85 expect(store.getItem("draft.u1/")).toBeNull();
86 timers.shift()!();
87 expect(store.getItem("draft.u1/")).toBe("Water");
88 box.value = "Water it"; draft.typed();
89 expect(timers.length).toBe(1);
90 });
91
92 test("a saved draft is put back into an empty box, never over typed words", () => {
93 const { store, box, draft } = rig();
94 store.setItem("draft.u1/", "saved");
95 draft.restore();
96 expect(box.value).toBe("saved");
97 box.value = "newer";
98 draft.restore();
99 expect(box.value).toBe("newer");
100 });
101
102 test("clearing drops the saved draft; an emptied box saves as nothing", () => {
103 const { store, box, timers, draft } = rig();
104 store.setItem("draft.u1/", "saved");
105 draft.clear();
106 expect(store.getItem("draft.u1/")).toBeNull();
107 box.value = "x"; draft.typed(); timers.shift()!();
108 box.value = ""; draft.typed(); timers.shift()!();
109 expect(store.getItem("draft.u1/")).toBeNull();
110 });
111
112 test("clearing cancels a save still waiting, so sent words are not saved back", () => {
113 const { store, box, timers, draft } = rig();
114 box.value = "sent"; draft.typed();
115 draft.clear();
116 timers.shift()!();
117 expect(store.getItem("draft.u1/")).toBeNull();
118 draft.typed();
119 expect(timers.length).toBe(1);
120 });
121
122 test("a refused send gives the words back, but not over what was typed since", () => {
123 const a = rig();
124 a.draft.refused("Mulch first");
125 expect(a.box.value).toBe("Mulch first");
126 expect(a.store.getItem("draft.u1/")).toBe("Mulch first");
127 const b = rig();
128 b.box.value = "Something new";
129 b.draft.refused("Mulch first");
130 expect(b.box.value).toBe("Something new");
131 expect(b.store.getItem("draft.u1/")).toBe("Something new");
132 });
133
134 test("forgetting removes every draft and nothing else", () => {
135 const { store, draft } = rig();
136 store.setItem("draft.u1/", "a");
137 store.setItem("draft.u2/studio", "b");
138 store.setItem("forge.outbox", "[]");
139 draft.forgetAll();
140 expect(store.getItem("draft.u1/")).toBeNull();
141 expect(store.getItem("draft.u2/studio")).toBeNull();
142 expect(store.getItem("forge.outbox")).toBe("[]");
143 });
144
145 const noChromium = findChromium() === undefined;
146
147 test.skipIf(noChromium)("in a real browser: type, reload, find it; send, reload, find it empty; a refusal keeps it; sign-out clears it", async () => {
148 const bin = findChromium()!;
149 await withPage(bin, async (send, once) => {
150 await send("Page.enable");
151 await send("Network.setCookie", { name: "forge_session", value: cookie, url: servers.url });
152 await send("Network.setCookie", { name: "forge_mode", value: "studio", url: servers.url });
153 const load = async () => {
154 const loaded = once("Page.loadEventFired");
155 loaded.catch(() => {});
156 await send("Page.navigate", { url: `${servers.url}/` });
157 await loaded;
158 await waitFor(`!!document.getElementById("composer")`);
159 };
160 const run = async (expression: string) => {
161 const r = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
162 if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? "evaluate failed");
163 return r.result.value;
164 };
165 const waitFor = async (expression: string) => {
166 const until = Date.now() + 8000;
167 while (Date.now() < until) {
168 if (await run(expression)) return;
169 await Bun.sleep(25);
170 }
171 throw new Error(`timed out waiting for ${expression}`);
172 };
173 const box = `document.getElementById("composer").elements.text`;
174 const type = (text: string) =>
175 run(`(() => { const b = ${box}; b.value = ${JSON.stringify(text)}; b.dispatchEvent(new Event("input", { bubbles: true })); })()`);
176
177 await load();
178 await type("Water the tomatoes at dawn");
179 await waitFor(`localStorage.getItem(${JSON.stringify(`draft.${alice.id}/`)}) === "Water the tomatoes at dawn"`);
180 await load();
181 expect(await run(`${box}.value`)).toBe("Water the tomatoes at dawn");
182
183 // Sending clears it.
184 await run(`(() => { document.getElementById("composer").elements.app.value = "alice/garden"; document.getElementById("composer").requestSubmit(); })()`);
185 await waitFor(`${box}.value === "" && !Object.keys(localStorage).some(k => k.startsWith("draft."))`);
186 await waitFor(`!localStorage.getItem("forge.outbox") || localStorage.getItem("forge.outbox") === "[]"`);
187 await load();
188 expect(await run(`${box}.value`)).toBe("");
189 expect(await run(`Object.keys(localStorage).filter(k => k.startsWith("draft."))`)).toEqual([]);
190
191 // Sending to the forge posts the form natively: the draft must not come back.
192 await run(`(() => { const c = document.getElementById("composer"); c.elements.text.value = "Plant beans"; c.elements.text.dispatchEvent(new Event("input", { bubbles: true })); c.elements.app.value = "forge"; })()`);
193 const navigated = once("Page.loadEventFired");
194 navigated.catch(() => {});
195 await run(`document.getElementById("composer").requestSubmit()`);
196 await navigated;
197 await waitFor(`!!document.getElementById("composer")`);
198 await Bun.sleep(1200); // a stale save would have fired by now
199 expect(await run(`${box}.value`)).toBe("");
200 expect(await run(`Object.keys(localStorage).filter(k => k.startsWith("draft."))`)).toEqual([]);
201
202 // A refused send (the app is gone) keeps the words.
203 await type("Mulch first");
204 await run(`(() => { const c = document.getElementById("composer"); const o = document.createElement("option"); o.value = "alice/nowhere"; c.elements.app.append(o); c.elements.app.value = "alice/nowhere"; c.requestSubmit(); })()`);
205 await waitFor(`!localStorage.getItem("forge.outbox") || localStorage.getItem("forge.outbox") === "[]"`);
206 await waitFor(`localStorage.getItem(${JSON.stringify(`draft.${alice.id}/`)}) === "Mulch first"`);
207 expect(await run(`${box}.value`)).toBe("Mulch first");
208 await load();
209 expect(await run(`${box}.value`)).toBe("Mulch first");
210
211 // Signing out clears every saved draft.
212 await run(`document.querySelector('form[action="/logout"]').requestSubmit()`);
213 await waitFor(`!Object.keys(localStorage).some(k => k.startsWith("draft."))`);
214 });
215 }, 90000);
216
217 test.skipIf(!noChromium)("no chromium on this machine: the composer draft browser test did not run", () => {});
test/offline.test.ts +5 −5
@@ -22,7 +22,7 @@ const make = new Function(`${outboxJs}; return forgeOutbox;`)() as (
2222 send: Send,
2323 ) => {
2424 add(url: string, body: unknown): void;
25 flush(): Promise<{ sent: number; left: number }>;
25 flush(): Promise<{ sent: number; refused?: number; left: number }>;
2626 size(): number;
2727 clear(): void;
2828 };
@@ -47,14 +47,14 @@ describe("the outbox", () => {
4747 });
4848 box.add("/api/v1/feed/interactions", { n: 1 });
4949 box.add("/api/v1/feed/tells", { n: 2 });
50 expect(await box.flush()).toEqual({ sent: 0, left: 2 });
50 expect(await box.flush()).toEqual({ sent: 0, refused: 0, left: 2 });
5151 // a reload: a new outbox over the same storage still holds them
5252 up = true;
5353 const reopened = make(store, async (_u, init) => {
5454 seen.push(init.body);
5555 return { status: 201 };
5656 });
57 expect(await reopened.flush()).toEqual({ sent: 2, left: 0 });
57 expect(await reopened.flush()).toEqual({ sent: 2, refused: 0, left: 0 });
5858 expect(seen).toEqual(['{"n":1}', '{"n":2}']);
5959 });
6060
@@ -83,9 +83,9 @@ describe("the outbox", () => {
8383 const box = make(store, async () => ({ status: statuses[calls++] ?? 201 }));
8484 box.add("/a", {});
8585 box.add("/b", {});
86 expect(await box.flush()).toEqual({ sent: 0, left: 2 });
86 expect(await box.flush()).toEqual({ sent: 0, refused: 0, left: 2 });
8787 expect(calls).toBe(1);
88 expect(await box.flush()).toEqual({ sent: 2, left: 0 });
88 expect(await box.flush()).toEqual({ sent: 1, refused: 1, left: 0 });
8989 });
9090
9191 test("two flushes at once send an entry once", async () => {
test/spawn-pipes.test.ts +1 −1
@@ -29,7 +29,7 @@ const PIPING: Record<string, { sites: string[]; reason: string }> = {
2929 "src/git/smart-ssh.ts": { sites: ["stdout+stderr"], reason: "pump() drains stdout (a pull-pipe FIFO) and stderr into the channel" },
3030 "src/kernel/data_home.ts": { sites: ["stdout"], reason: "spawnSync buffers stdout; stderr ignored" },
3131 "src/looks/capture.ts": { sites: ["stderr"], reason: "stderr read in a background loop; stdout ignored" },
32 "src/release/axe.ts": { sites: ["stderr"], reason: "stderr read for the DevTools address, then drained in a background loop; stdout ignored" },
32 "src/release/devtools.ts": { sites: ["stderr"], reason: "stderr read for the DevTools address, then drained in a background loop; stdout ignored" },
3333 "src/release/boot.ts": { sites: ["stderr"], reason: "stderr read to its end in a background loop, awaited before the reason is read; stdout ignored" },
3434 "src/release/checkout.ts": { sites: ["stdout+stderr"], reason: "stdout and stderr both read via Response.text()" },
3535 "src/cli/config.ts": { sites: ["stdout"], reason: "stdout awaited with the exit; stderr ignored" },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs