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

Merged opened this

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

Merged

mason/forge:mason/11336-a-tell-sent-from-an-app-s-forge-toolbar → main 5 commits fork

  • Merged. mason/11336-a-tell-sent-from-an-app-s-forge-toolbar was merged into main as 50b0ac6.

Review

Sign in to review this change.
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.";
1515 /** A note longer than one issue body may be. */
1616 export const NOTE_TOO_LONG = "That is too long to send in one go.";
1717
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 @@
1414 import { staticResponse } from "../web/asset.ts";
1515 import { sha256 } from "../kernel/hash.ts";
1616 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";
1818
1919 /** What the panel says about a preview's freshness (#9111): the pill's words, in one table. */
2020 export const FRESHNESS_WORDS = {
@@ -308,7 +308,7 @@ const toolbarSource = `(function () {
308308 headers: { "content-type": "application/json" },
309309 body: JSON.stringify({ text: text, page: location.href, viewport: window.innerWidth + "x" + window.innerHeight, errors: errs.slice(-5) })
310310 })
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 }; }); })
312312 .then(function (a) {
313313 if (!a.ok || !blob || !a.j || !a.j.note || !a.j.note.id) return { a: a, pic: false };
314314 return call("/__forge/note/picture?note=" + encodeURIComponent(a.j.note.id), {
@@ -329,9 +329,9 @@ const toolbarSource = `(function () {
329329 filed(a.j && a.j.note);
330330 refresh(true);
331331 }
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)};
333333 })
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)}; });
335335 });
336336 var poll = 0;
337337 function watch(open) {
test/apps-toolbar-tell.test.ts +55
@@ -13,6 +13,7 @@ import { createApp, type App } from "../src/app.ts";
1313 import { newApp } from "../src/actions/apps.ts";
1414 import { tellLink } from "../src/apps/crew.ts";
1515 import { toolbarJs } from "../src/apps/toolbar.ts";
16 import { NOTE_FAILED, NOTE_OFFLINE } from "../src/apps/note_words.ts";
1617 import { getAppByRepo, type AppRecord } from "../src/core/apps.ts";
1718 import { listIssues, MAX_BODY } from "../src/core/issues.ts";
1819 import { createToken } from "../src/core/tokens.ts";
@@ -237,3 +238,57 @@ describe("tell the crew from the pill", () => {
237238 expect(told().length).toBe(before);
238239 });
239240 });
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 => ({
2121 textContent: "",
2222 attrs: {} as Record<string, string>,
2323 setAttribute(k: string, v: string) { this.attrs[k] = v; },
24 removeAttribute(k: string) { delete this.attrs[k]; },
2425 appendChild(c: El) { this.children.push(c); return c; },
2526 addEventListener(t: string, f: (e?: any) => void) { (this.listeners[t] ??= []).push(f); },
2627 focus() {},

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs