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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5901-the-feed-composer-keeps-what-you-typed-a → main 6 commits fork
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 | ||
| 688 | 688 | - [](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. |
| 689 | 689 | - [](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. |
| 690 | 690 | - [](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) — the feed: what happened, ranked, each item with why it is there. | |
| 691 | - [](docs/FEED.md) — the feed: what happened, ranked, each item with why it is there. The home composer keeps your draft across a reload. | |
| 692 | 692 | - [](docs/PULLS.md) — resolving a conflicted pull without a clone. |
| 693 | 693 | - [](docs/GIT.md) — cloning over HTTPS and SSH, and partial clones of big repositories. |
| 694 | 694 | - [](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`); | ||
| 142 | 142 | still names one). The list swaps in place and the top bar's count follows. One undo link |
| 143 | 143 | (`POST /api/v1/asks/bulk/<id>/undo`) is kept for a day. The MCP's `asks_act` |
| 144 | 144 | 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 @@ | ||
| 14 | 14 | * roles) need a rendered page and a computed accessibility tree. |
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 18 | import { tmpdir } from "node:os"; | |
| 19 | import { join } from "node:path"; | |
| 20 | 17 | import { findChromium } from "../looks/capture.ts"; |
| 18 | import { withPage } from "./devtools.ts"; | |
| 21 | 19 | import { s, type Finding } from "./webspec.ts"; |
| 22 | 20 | |
| 23 | 21 | const SCHEMES = ["light", "dark"] as const; |
| 24 | const STEP_MS = 15000; | |
| 25 | 22 | |
| 26 | 23 | export interface AxeViolation { |
| 27 | 24 | id: string; |
| @@ -69,87 +66,6 @@ export async function noScript(base: string, paths: string[], fetcher: typeof fe | ||
| 69 | 66 | return findings; |
| 70 | 67 | } |
| 71 | 68 | |
| 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 | ||
| 153 | 69 | /** Runs axe-core over each path in each colour scheme, once each, and reports its violations. */ |
| 154 | 70 | export async function axe(base: string, paths: string[]): Promise<Finding[]> { |
| 155 | 71 | 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) { | ||
| 49 | 49 | function run() { |
| 50 | 50 | var list = read(); |
| 51 | 51 | if (list.length === 0) return Promise.resolve({ sent: 0, left: 0 }); |
| 52 | var sent = 0; | |
| 52 | var sent = 0, refused = 0; | |
| 53 | 53 | function next() { |
| 54 | 54 | var entry = read()[0]; |
| 55 | 55 | if (!entry) return Promise.resolve(); |
| @@ -60,11 +60,11 @@ function forgeOutbox(storage, send, now) { | ||
| 60 | 60 | }).then(function (r) { |
| 61 | 61 | if (!final(r.status)) return; |
| 62 | 62 | write(read().filter(function (e) { return e.key !== entry.key; })); |
| 63 | sent++; | |
| 63 | if (r.status < 300) sent++; else refused++; | |
| 64 | 64 | return next(); |
| 65 | 65 | }, function () {}); |
| 66 | 66 | } |
| 67 | return next().then(function () { return { sent: sent, left: read().length }; }); | |
| 67 | return next().then(function () { return { sent: sent, refused: refused, left: read().length }; }); | |
| 68 | 68 | } |
| 69 | 69 | // One flush at a time: two tabs of one page share storage, two flushes in |
| 70 | 70 | // 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"; | ||
| 24 | 24 | import { dividers } from "./day_divider.ts"; |
| 25 | 25 | import { html, type Html } from "../html.ts"; |
| 26 | 26 | import { fragment } from "../layout.ts"; |
| 27 | import { draftJs } from "../draft.ts"; | |
| 27 | 28 | import { outboxJs } from "../outbox.ts"; |
| 28 | 29 | import { avatar, btn, card, disclosure, empty, nameMark, stateBadge, time, timeAgo } from "../ui.ts"; |
| 29 | 30 | import { writableAppsOf } from "./asks.ts"; |
| @@ -221,7 +222,7 @@ export function composer(ctx: Ctx, user: User): Html { | ||
| 221 | 222 | })}`; |
| 222 | 223 | const form = html`<form id="composer" class="card" method="post" action="/tell"> |
| 223 | 224 | <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> | |
| 225 | 226 | <div class="bar"> |
| 226 | 227 | ${repos.length === 0 |
| 227 | 228 | ? html`<input type="hidden" name="app" value="${FORGE_TARGET}" /><span class="muted small">the forge</span>` |
| @@ -419,6 +420,7 @@ function keepDay(card) { | ||
| 419 | 420 | */ |
| 420 | 421 | export const feedJs = /* js */ ` |
| 421 | 422 | ${outboxJs} |
| 423 | ${draftJs} | |
| 422 | 424 | ${dayKeepJs} |
| 423 | 425 | (function () { |
| 424 | 426 | var feed = document.getElementById('live-feed'); |
| @@ -545,15 +547,27 @@ ${dayKeepJs} | ||
| 545 | 547 | // The composer is a tell too: kept and sent the same way; the box clears |
| 546 | 548 | // at once, and the post arrives in the timeline when the forge says so. |
| 547 | 549 | 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 | } | |
| 548 | 557 | if (composer) composer.addEventListener('submit', function (e) { |
| 549 | 558 | var box = composer.elements.text; |
| 550 | 559 | var app = composer.elements.app; |
| 551 | 560 | 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; | |
| 553 | 566 | if (!queue('/api/v1/feed/tells', { app: app.value, text: text })) return; |
| 554 | 567 | e.preventDefault(); |
| 555 | 568 | box.value = ''; |
| 556 | 569 | box.focus(); |
| 570 | outbox.flush().then(function (r) { if (r.refused && draft) draft.refused(text); }); | |
| 557 | 571 | }); |
| 558 | 572 | document.addEventListener('keydown', function (e) { |
| 559 | 573 | if (e.key !== 'x' || e.metaKey || e.ctrlKey || e.altKey) return; |
| @@ -571,6 +585,7 @@ ${dayKeepJs} | ||
| 571 | 585 | document.addEventListener('submit', function (e) { |
| 572 | 586 | if (e.target && e.target.getAttribute && e.target.getAttribute('action') === '/logout') { |
| 573 | 587 | if (outbox) outbox.clear(); |
| 588 | if (draft) draft.forgetAll(); | |
| 574 | 589 | if (navigator.serviceWorker && navigator.serviceWorker.controller) navigator.serviceWorker.controller.postMessage('clear'); |
| 575 | 590 | } |
| 576 | 591 | }); |
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 ( | ||
| 22 | 22 | send: Send, |
| 23 | 23 | ) => { |
| 24 | 24 | add(url: string, body: unknown): void; |
| 25 | flush(): Promise<{ sent: number; left: number }>; | |
| 25 | flush(): Promise<{ sent: number; refused?: number; left: number }>; | |
| 26 | 26 | size(): number; |
| 27 | 27 | clear(): void; |
| 28 | 28 | }; |
| @@ -47,14 +47,14 @@ describe("the outbox", () => { | ||
| 47 | 47 | }); |
| 48 | 48 | box.add("/api/v1/feed/interactions", { n: 1 }); |
| 49 | 49 | 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 }); | |
| 51 | 51 | // a reload: a new outbox over the same storage still holds them |
| 52 | 52 | up = true; |
| 53 | 53 | const reopened = make(store, async (_u, init) => { |
| 54 | 54 | seen.push(init.body); |
| 55 | 55 | return { status: 201 }; |
| 56 | 56 | }); |
| 57 | expect(await reopened.flush()).toEqual({ sent: 2, left: 0 }); | |
| 57 | expect(await reopened.flush()).toEqual({ sent: 2, refused: 0, left: 0 }); | |
| 58 | 58 | expect(seen).toEqual(['{"n":1}', '{"n":2}']); |
| 59 | 59 | }); |
| 60 | 60 | |
| @@ -83,9 +83,9 @@ describe("the outbox", () => { | ||
| 83 | 83 | const box = make(store, async () => ({ status: statuses[calls++] ?? 201 })); |
| 84 | 84 | box.add("/a", {}); |
| 85 | 85 | 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 }); | |
| 87 | 87 | 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 }); | |
| 89 | 89 | }); |
| 90 | 90 | |
| 91 | 91 | 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 }> = { | ||
| 29 | 29 | "src/git/smart-ssh.ts": { sites: ["stdout+stderr"], reason: "pump() drains stdout (a pull-pipe FIFO) and stderr into the channel" }, |
| 30 | 30 | "src/kernel/data_home.ts": { sites: ["stdout"], reason: "spawnSync buffers stdout; stderr ignored" }, |
| 31 | 31 | "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" }, | |
| 33 | 33 | "src/release/boot.ts": { sites: ["stderr"], reason: "stderr read to its end in a background loop, awaited before the reason is read; stdout ignored" }, |
| 34 | 34 | "src/release/checkout.ts": { sites: ["stdout+stderr"], reason: "stdout and stderr both read via Response.text()" }, |
| 35 | 35 | "src/cli/config.ts": { sites: ["stdout"], reason: "stdout awaited with the exit; stderr ignored" }, |
new above
offline