Public: anyone can read this; the crew can change it.
A tell sent from an app's forge toolbar that fails keeps the typed words and says why, in one plain line #11348
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11336-a-tell-sent-from-an-app-s-forge-toolbar → main 5 commits fork
4 files
changed +66
−4
src/apps/note_words.ts +6
| @@ -15,3 +15,9 @@ export const NOTE_FAILED = "That did not go through. Try again."; | ||
| 15 | 15 | /** A note longer than one issue body may be. */ |
| 16 | 16 | export const NOTE_TOO_LONG = "That is too long to send in one go."; |
| 17 | 17 | |
| 18 | ||
| 19 | /** No network: the words stay in the box. */ | |
| 20 | export const NOTE_OFFLINE = "You are offline. Your words are still here; send again when you are back."; | |
| 21 | ||
| 22 | /** Too many notes: the wait, in seconds, goes between the two halves. */ | |
| 23 | export const NOTE_WAIT = ["Too many notes just now. Your words are still here; try again in ", " seconds."] as const; |
src/apps/toolbar.ts +4 −4
| @@ -14,7 +14,7 @@ | ||
| 14 | 14 | import { staticResponse } from "../web/asset.ts"; |
| 15 | 15 | import { sha256 } from "../kernel/hash.ts"; |
| 16 | 16 | import { CHECK_WORDS, type PillCheck } from "./pill_check.ts"; |
| 17 | import { NOTE_EMPTY, NOTE_FAILED } from "./note_words.ts"; | |
| 17 | import { NOTE_EMPTY, NOTE_FAILED, NOTE_OFFLINE, NOTE_WAIT } from "./note_words.ts"; | |
| 18 | 18 | |
| 19 | 19 | /** What the panel says about a preview's freshness (#9111): the pill's words, in one table. */ |
| 20 | 20 | export const FRESHNESS_WORDS = { |
| @@ -308,7 +308,7 @@ const toolbarSource = `(function () { | ||
| 308 | 308 | headers: { "content-type": "application/json" }, |
| 309 | 309 | body: JSON.stringify({ text: text, page: location.href, viewport: window.innerWidth + "x" + window.innerHeight, errors: errs.slice(-5) }) |
| 310 | 310 | }) |
| 311 | .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); }) | |
| 311 | .then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, s: r.status, w: r.headers.get("retry-after"), j: j }; }); }) | |
| 312 | 312 | .then(function (a) { |
| 313 | 313 | if (!a.ok || !blob || !a.j || !a.j.note || !a.j.note.id) return { a: a, pic: false }; |
| 314 | 314 | return call("/__forge/note/picture?note=" + encodeURIComponent(a.j.note.id), { |
| @@ -329,9 +329,9 @@ const toolbarSource = `(function () { | ||
| 329 | 329 | filed(a.j && a.j.note); |
| 330 | 330 | refresh(true); |
| 331 | 331 | } |
| 332 | else tellSay.textContent = a.j && a.j.message ? a.j.message : ${JSON.stringify(NOTE_FAILED)}; | |
| 332 | else tellSay.textContent = a.s === 429 && +a.w > 0 ? ${JSON.stringify(NOTE_WAIT[0])} + Math.ceil(+a.w) + ${JSON.stringify(NOTE_WAIT[1])} : a.j && a.j.message ? a.j.message : ${JSON.stringify(NOTE_FAILED)}; | |
| 333 | 333 | }) |
| 334 | .catch(function () { idle(); tellSay.textContent = ${JSON.stringify(NOTE_FAILED)}; }); | |
| 334 | .catch(function () { idle(); tellSay.textContent = navigator.onLine === false ? ${JSON.stringify(NOTE_OFFLINE)} : ${JSON.stringify(NOTE_FAILED)}; }); | |
| 335 | 335 | }); |
| 336 | 336 | var poll = 0; |
| 337 | 337 | function watch(open) { |
test/apps-toolbar-tell.test.ts +55
| @@ -13,6 +13,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 13 | 13 | import { newApp } from "../src/actions/apps.ts"; |
| 14 | 14 | import { tellLink } from "../src/apps/crew.ts"; |
| 15 | 15 | import { toolbarJs } from "../src/apps/toolbar.ts"; |
| 16 | import { NOTE_FAILED, NOTE_OFFLINE } from "../src/apps/note_words.ts"; | |
| 16 | 17 | import { getAppByRepo, type AppRecord } from "../src/core/apps.ts"; |
| 17 | 18 | import { listIssues, MAX_BODY } from "../src/core/issues.ts"; |
| 18 | 19 | import { createToken } from "../src/core/tokens.ts"; |
| @@ -237,3 +238,57 @@ describe("tell the crew from the pill", () => { | ||
| 237 | 238 | expect(told().length).toBe(before); |
| 238 | 239 | }); |
| 239 | 240 | }); |
| 241 | ||
| 242 | describe("a tell from the toolbar that fails keeps the words and says why", () => { | |
| 243 | const sendWith = async (answer: () => Promise<unknown>, online = true) => { | |
| 244 | const { boot } = await import("./helpers/toolbar-boot.ts"); | |
| 245 | const realFetch = globalThis.fetch; | |
| 246 | const nav = Object.getOwnPropertyDescriptor(globalThis, "navigator"); | |
| 247 | (globalThis as any).fetch = answer; | |
| 248 | Object.defineProperty(globalThis, "navigator", { value: { onLine: online }, configurable: true }); | |
| 249 | (globalThis as any).location = { href: "https://notes.apps.test/" }; | |
| 250 | try { | |
| 251 | const b = boot(null); | |
| 252 | const all = (e: any): any[] => [e, ...e.children.flatMap(all)]; | |
| 253 | const els = all(b.panel); | |
| 254 | const form = els.find((e) => e.tag === "form" && e.attrs["aria-label"] === "Tell the builders")!; | |
| 255 | const area = els.find((e) => e.tag === "textarea")!; | |
| 256 | const say = els.find((e) => e.attrs["aria-live"] === "polite")!; | |
| 257 | area.value = "make it purple"; | |
| 258 | b.fire(form, "submit"); | |
| 259 | for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0)); | |
| 260 | return { area, say: say.textContent as string }; | |
| 261 | } finally { | |
| 262 | (globalThis as any).fetch = realFetch; | |
| 263 | delete (globalThis as any).location; | |
| 264 | if (nav) Object.defineProperty(globalThis, "navigator", nav); | |
| 265 | } | |
| 266 | }; | |
| 267 | const reply = (status: number, body: unknown, headers: Record<string, string> = {}) => async () => ({ | |
| 268 | ok: status < 400, | |
| 269 | status, | |
| 270 | headers: { get: (k: string) => headers[k.toLowerCase()] ?? null }, | |
| 271 | json: async () => body, | |
| 272 | }); | |
| 273 | ||
| 274 | test("a rate limit says how many seconds to wait", async () => { | |
| 275 | const r = await sendWith(reply(429, { error: "rate_limited", message: "x" }, { "retry-after": "42" })); | |
| 276 | expect(r.area.value).toBe("make it purple"); | |
| 277 | expect(r.say).toContain("42 seconds"); | |
| 278 | }); | |
| 279 | ||
| 280 | test("a refusal says its reason and an answer that is not JSON still says something", async () => { | |
| 281 | const refused = await sendWith(reply(403, { error: "banned", message: "You cannot send notes here." })); | |
| 282 | expect(refused.area.value).toBe("make it purple"); | |
| 283 | expect(refused.say).toBe("You cannot send notes here."); | |
| 284 | const broken = await sendWith(async () => ({ ok: false, status: 502, headers: { get: () => null }, json: async () => { throw new Error("html"); } })); | |
| 285 | expect(broken.area.value).toBe("make it purple"); | |
| 286 | expect(broken.say).toBe(NOTE_FAILED); | |
| 287 | }); | |
| 288 | ||
| 289 | test("offline, the words stay and the line says so", async () => { | |
| 290 | const r = await sendWith(async () => { throw new TypeError("network"); }, false); | |
| 291 | expect(r.area.value).toBe("make it purple"); | |
| 292 | expect(r.say).toBe(NOTE_OFFLINE); | |
| 293 | }); | |
| 294 | }); |
test/helpers/toolbar-boot.ts +1
| @@ -21,6 +21,7 @@ const make = (tag: string): El => ({ | ||
| 21 | 21 | textContent: "", |
| 22 | 22 | attrs: {} as Record<string, string>, |
| 23 | 23 | setAttribute(k: string, v: string) { this.attrs[k] = v; }, |
| 24 | removeAttribute(k: string) { delete this.attrs[k]; }, | |
| 24 | 25 | appendChild(c: El) { this.children.push(c); return c; }, |
| 25 | 26 | addEventListener(t: string, f: (e?: any) => void) { (this.listeners[t] ??= []).push(f); }, |
| 26 | 27 | focus() {}, |