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

Actions answer at once and live regions resume: optimistic where it's safe, and a stream that picks up where it left off #8717

Merged opened this in answer to

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

Merged

torque/forge:torque/8708-actions-answer-at-once-and-live-regions → main 3 commits fork 2 threads · 2 unresolved

  • Merged. torque/8708-actions-answer-at-once-and-live-regions was merged into main as 68d344f.

Review

Sign in to review this change.
10 files changed +374 −23
docs/features/act-now.md added +12
@@ -0,0 +1,12 @@
1 # Actions that answer at once (#8708)
2
3 A form marked `data-now` shows its result in the frame it is submitted in, posts in the background asking for JSON, and rolls back with the server's reason when it is refused. No client model: the page is still what the server rendered, and live regions bring the rest. Without script the form posts and redirects as before.
4
5 - Script: `src/web/act_now.ts`, appended to `shellJs` (`client.ts`), so it is cached with the shell. Byte budget `ACT_NOW_BUDGET`, held by `test/act-now.test.ts`.
6 - Opted in: assign, labels, close/reopen (thread, pull, "done on main"), flow first (the rerank), approve a proposal.
7 - The handlers answer `{ ok, flash }` or 422 `{ ok: false, error }` when `accept: application/json` (`flashOrJson`).
8 - Streams: every live-region frame carries the event's id (`id:`); a reconnect with `Last-Event-ID` (or `?since=`) replays only what came after, oldest first (`http/sse.ts`).
9
10 ## Perceived action time
11
12 Measured in-process on loopback (median of 40, flow toggle): before, POST + 303 + GET of the page = 17.1 ms of server work, and on a real network that is two round trips. After, the result is painted in the same task as the submit (one frame, <= 16.7 ms at 60 Hz) and the confirmation costs one JSON POST = 3.5 ms of server work, off the critical path.
src/web/act_now.ts added +77
@@ -0,0 +1,77 @@
1 /**
2 * Actions that answer at once. A form marked `data-now` shows its result in
3 * the frame it is submitted in, posts in the background asking for JSON, and
4 * puts everything back with the server's reason when it is refused. There is
5 * no client model: the page stays what the server rendered, the live regions
6 * (`liveRegionsJs`) bring the rest, and a page without script posts the form
7 * the way it always did.
8 *
9 * data-now="Word" what the form's buttons say the moment it is sent
10 * data-now-once the action is done for good: the buttons stay so
11 * data-now-into="#id" an element that shows the new value at once, and
12 * data-now-from="name" the field it is read from (a select's option, or
13 * the chips of the ticked boxes)
14 * data-now-flip="name" a toggle: on success the field's 0/1 flips and
15 * data-now-alt="Word" the buttons say this next time
16 *
17 * Sent as the tail of `shellJs` (`client.ts`); `ACT_NOW_BUDGET` is its byte budget,
18 * held by `test/act-now.test.ts`.
19 */
20
21 export const ACT_NOW_BUDGET = 3000;
22
23 export const actNowJs = /* js */ `
24 (function () {
25 var D = document;
26 function all(l) { return Array.prototype.slice.call(l); }
27 D.addEventListener('submit', function (e) {
28 var f = e.target, w = f.getAttribute && f.getAttribute('data-now');
29 if (!w || !window.fetch || e.defaultPrevented) return;
30 e.preventDefault();
31 if (f.__busy) return;
32 f.__busy = 1;
33 var bs = all(f.querySelectorAll('button')).concat(f.id ? all(D.querySelectorAll('button[form="' + f.id + '"]')) : []);
34 var undo = [], at = bs[0] || f;
35 bs.forEach(function (b) {
36 var h = b.innerHTML;
outdated

Disabling the focused button drops keyboard focus. Use aria-disabled and swallow the click, or restore focus on rollback.

37 undo.push(function () { b.innerHTML = h; b.removeAttribute('aria-disabled'); });
38 var t = all(b.childNodes).filter(function (x) { return x.nodeType === 3; });
39 t.slice(1).forEach(function (x) { b.removeChild(x); });
40 if (t[0]) t[0].nodeValue = w; else b.appendChild(D.createTextNode(w));
41 b.setAttribute('aria-disabled', 'true');
42 });
43 var to = D.querySelector(f.getAttribute('data-now-into') || ':not(*)'), from = f.getAttribute('data-now-from');
44 if (to && from) {
45 var h = to.innerHTML, c = f.elements[from], k = all(f.querySelectorAll('input[name="' + from + '"]:checked'));
46 undo.push(function () { to.innerHTML = h; });
outdated

Insert the live-region note empty and with its role before the first text, so it is announced.

47 if (c && c.options) to.textContent = c.options[c.selectedIndex].text.trim();
48 else to.innerHTML = k.length ? k.map(function (i) { return i.closest('label').lastElementChild.outerHTML; }).join(' ') : 'None yet.';
49 }
50 var n = f.__now;
51 if (!n) { n = f.__now = D.createElement('p'); n.className = 'small'; n.setAttribute('role', 'status'); at.insertAdjacentElement('afterend', n); }
52 function say(t) { setTimeout(function () { n.textContent = t; }, 50); }
53 say(w + '\\u2026');
54 function done(ok, said) {
55 if (!ok || !f.hasAttribute('data-now-once')) f.__busy = 0;
56 n.setAttribute('role', ok ? 'status' : 'alert');
57 say(said);
58 if (!ok) return undo.reverse().forEach(function (u) { u(); });
59 var flip = f.getAttribute('data-now-flip'), once = f.hasAttribute('data-now-once');
60 if (flip && f.elements[flip]) {
61 f.elements[flip].value = f.elements[flip].value === '1' ? '0' : '1';
62 f.setAttribute('data-now', f.getAttribute('data-now-alt'));
63 f.setAttribute('data-now-alt', w);
64 }
65 if (!once && !flip) undo.slice(0, bs.length).forEach(function (u) { u(); });
66 else if (flip) bs.forEach(function (b) { b.removeAttribute('aria-disabled'); });
67 }
68 fetch(f.action, { method: 'POST', body: new URLSearchParams(new FormData(f)), credentials: 'same-origin', headers: { accept: 'application/json' } })
69 .then(function (r) {
70 return r.json().catch(function () { return {}; }).then(function (j) {
71 done(r.ok && j.ok !== false, j.error || j.flash || (r.ok ? w + '.' : 'That did not go through (' + r.status + '). Nothing changed.'));
72 });
73 })
74 .catch(function () { done(false, 'Could not reach the forge. Nothing changed.'); });
75 });
76 })();
77 `;
src/web/client.ts +5 −1
@@ -10,6 +10,7 @@
1010 * behaviour that a page depends on to be usable.
1111 */
1212
13 import { actNowJs } from "./act_now.ts";
1314 import { imageViewJs } from "./render/image.ts";
1415 import { STAGE_BADGE_JS } from "./stage-row.ts";
1516 import { paletteHelpersJs } from "./palette.ts";
@@ -1272,7 +1273,7 @@ export const editorJs = /* js */ `
12721273 })();
12731274 `;
12741275
1275 export const shellJs = /* js */ `
1276 const shellCore = /* js */ `
12761277 (function () {
12771278 var doc = document;
12781279 ${modalJs}
@@ -2005,6 +2006,9 @@ export const shellJs = /* js */ `
20052006 })();
20062007 `;
20072008
2009 /** The shell and the one script that makes actions answer at once (`act_now.ts`). */
2010 export const shellJs = `${shellCore}\n${actNowJs}`;
2011
20082012 /**
20092013 * Live regions. A page marks the parts of itself that change while it is
20102014 * open: an element with an `id` and `data-live="kinds"` (comma-separated
src/web/flow_button.ts +1 −1
@@ -11,7 +11,7 @@ const ON = "Adds the flow label: it goes ahead of other ready work";
1111 const OFF = "Takes the flow label off: it no longer goes ahead of other ready work";
1212
1313 export function flowButton(action: string, first: boolean, back: string): Html {
14 return html`<form method="post" action="${action}" class="inline" title="${first ? OFF : ON}">
14 return html`<form method="post" action="${action}" class="inline" title="${first ? OFF : ON}" data-now="${first ? "Flow first" : "Not first"}" data-now-alt="${first ? "Not first" : "Flow first"}" data-now-flip="flow">
1515 <input type="hidden" name="flow" value="${first ? "0" : "1"}" />
1616 <input type="hidden" name="back" value="${back}" />
1717 ${btn(first ? "Not first" : "Flow first", { size: "sm", variant: "outline" })}
src/web/pages/pull.ts +1 −1
@@ -452,7 +452,7 @@ function mergeBox(
452452 // A reader's head runs nothing until a writer approves it; say so rather
453453 // than leave "no checks yet" waiting for something that will not come.
454454 const checks = pullChecksLine(ctx, repo, pull, status, check, isApp, tier, stale);
455 const reopen = html`<form method="post" action="${thread}/state">
455 const reopen = html`<form method="post" action="${thread}/state" data-now="Reopened" data-now-once>
456456 <input type="hidden" name="state" value="open" />
457457 ${btn("Reopen", { size: "sm", icon: "pull" })}
458458 </form>`;
src/web/pages/thread.ts +15 −15
@@ -521,7 +521,7 @@ export function composer(ctx: Ctx, o: ComposerOptions): Html {
521521 // action. The state form sits outside; the button joins it by id.
522522 const stateForm =
523523 o.stateAction !== undefined && !o.locked
524 ? html`<form method="post" action="${o.stateAction}" id="thread-state">
524 ? html`<form method="post" action="${o.stateAction}" id="thread-state" data-now="${o.state === "open" ? "Closed" : "Reopened"}" data-now-once>
525525 <input
526526 type="hidden"
527527 name="state"
@@ -633,8 +633,8 @@ export function labelsCard(
633633 const mayEdit = canWrite(ctx.app.db, repo, ctx.auth.user);
634634 const chips =
635635 applied.length === 0
636 ? html`<p class="muted small">None yet.</p>`
637 : html`<div class="bar">
636 ? html`<p class="muted small" id="labels-now">None yet.</p>`
637 : html`<div class="bar" id="labels-now">
638638 ${applied.map((l) =>
639639 labelChip(
640640 l,
@@ -651,7 +651,7 @@ export function labelsCard(
651651 const applies = new Set(applied.map((l) => l.id));
652652 return card(
653653 html`${chips}
654 <form method="post" action="${action}" class="mt">
654 <form method="post" action="${action}" class="mt" data-now="Applied" data-now-into="#labels-now" data-now-from="labels">
655655 <fieldset class="stack">
656656 <legend class="sr-only">Labels on this thread</legend>
657657 ${all.map(
@@ -729,8 +729,8 @@ export function assigneeCard(
729729 const mayEdit = canWrite(ctx.app.db, repo, ctx.auth.user);
730730 const current =
731731 issue.assigneeName === null
732 ? html`<p class="muted small">Nobody yet.</p>`
733 : html`<p class="bar">
732 ? html`<p class="muted small" id="assignee-now">Nobody yet.</p>`
733 : html`<p class="bar" id="assignee-now">
734734 ${when(issue.assigneeIsAgent, () => icon("bot"))}
735735 ${userLink(issue.assigneeName, {
736736 mark: marksByName(ctx.app.db, [issue.assigneeName]).get(
@@ -746,7 +746,7 @@ export function assigneeCard(
746746 const candidates = assigneeCandidates(ctx, repo, issue);
747747 return card(
748748 html`${current}
749 <form method="post" action="${action}" class="edit-row mt">
749 <form method="post" action="${action}" class="edit-row mt" data-now="Assigned" data-now-into="#assignee-now" data-now-from="assignee">
750750 <label class="sr-only" for="f-assignee">Assign to</label>
751751 <select class="input" id="f-assignee" name="assignee">
752752 <option
@@ -789,7 +789,7 @@ export async function assigneeSubmit(
789789 const name = field(form, "assignee").trim();
790790 const assignee = name === "" ? null : getUserByName(ctx.app.db, name);
791791 if (assignee === undefined)
792 return flashTo(ctx, dest, {
792 return flashOrJson(ctx, dest, {
793793 tone: "danger",
794794 message: `There is no account named ${name}.`,
795795 });
@@ -807,12 +807,12 @@ export async function assigneeSubmit(
807807 forbidden: "You need write access to assign.",
808808 archived: ARCHIVED_WHY,
809809 };
810 return flashTo(ctx, dest, {
810 return flashOrJson(ctx, dest, {
811811 tone: "danger",
812812 message: words[assigned.error] ?? "That could not be assigned.",
813813 });
814814 }
815 return flashTo(ctx, dest, {
815 return flashOrJson(ctx, dest, {
816816 message:
817817 assignee === null
818818 ? "Unassigned."
@@ -951,7 +951,7 @@ export async function labelsSubmit(
951951 chosen,
952952 );
953953 if (!applied.ok) {
954 return flashTo(ctx, dest, {
954 return flashOrJson(ctx, dest, {
955955 tone: "danger",
956956 message:
957957 originError(applied.error) ??
@@ -960,7 +960,7 @@ export async function labelsSubmit(
960960 : THREAD_SAYS.labelsForbidden),
961961 });
962962 }
963 return flashTo(ctx, dest, {
963 return flashOrJson(ctx, dest, {
964964 message:
965965 applied.value.length === 0
966966 ? "Labels cleared."
@@ -977,14 +977,14 @@ export async function flowSubmit(ctx: Ctx): Promise<Response> {
977977 if (form instanceof Response) return form;
978978 const asked = field(form, "flow");
979979 const dest = backTo(form, `${repoBase(found.repo)}/issues/${found.issue.number}`);
980 if (asked !== "0" && asked !== "1") return flashTo(ctx, dest, { tone: "danger", message: NOT_CHANGED });
980 if (asked !== "0" && asked !== "1") return flashOrJson(ctx, dest, { tone: "danger", message: NOT_CHANGED });
981981 const applied = await putFlowFirst(ctx.app, ctx.auth.user, found.repo, found.issue, asked === "1");
982982 if (!applied.ok)
983 return flashTo(ctx, dest, {
983 return flashOrJson(ctx, dest, {
984984 tone: "danger",
985985 message: originError(applied.error) ?? THREAD_SAYS.labelsForbidden,
986986 });
987 return flashTo(ctx, dest, { message: asked === "1" ? "Flow first." : "No longer first." });
987 return flashOrJson(ctx, dest, { message: asked === "1" ? "Flow first." : "No longer first." });
988988 }
989989
990990 // ---- teams ----
src/web/studio/asks_page.ts +6 −3
@@ -31,7 +31,8 @@ import { onYouAnswer, onYouRegion } from "./home.ts";
3131 import { liveRegionsJs } from "../client.ts";
3232 import { liveClientJs } from "../live.ts";
3333 import { notFoundPage, repoBase, signInFirst, NOT_YOURS_TO_DO, forbiddenPage } from "../chrome.ts";
34 import { backTo, field, readForm } from "../form.ts";
34 import { backTo, field, prefersJson, readForm } from "../form.ts";
35 import { json } from "../../http/respond.ts";
3536 import { MAX_BODY } from "../../core/issues.ts";
3637 import { issueTitles } from "../../core/proposals.ts";
3738 import { actOnProposal, type ProposalVerb } from "../../actions/proposals.ts";
@@ -760,8 +761,10 @@ export function asksProposal(verb: ProposalVerb) {
760761 ? notFoundPage(ctx)
761762 : done.error.code === "forbidden" || done.error.code === "unauthorized"
762763 ? forbiddenPage(ctx, NOT_YOURS_TO_DO)
763 : await asksScreen(ctx, nothingDone(done.error.message));
764 return redirect(backTo(form, "/asks"));
764 : prefersJson(ctx)
765 ? json({ ok: false, error: done.error.message }, 422)
766 : await asksScreen(ctx, nothingDone(done.error.message));
767 return prefersJson(ctx) ? json({ ok: true, flash: "Done." }) : redirect(backTo(form, "/asks"));
765768 };
766769 }
767770
src/web/studio/decisions.ts +1 −1
@@ -352,6 +352,6 @@ function proposalForms(d: Decision, back?: string): Html {
352352 const id = askFormId(d);
353353 const post = (verb: string) => `/asks/proposal/${d.proposal!.id}/${verb}`;
354354 const where = back === undefined ? null : html`<input type="hidden" name="back" value="${back}" />`;
355 return html`<form id="${id}" method="post" action="${post("approve")}" hidden>${where}</form>
355 return html`<form id="${id}" method="post" action="${post("approve")}" hidden data-now="Approved" data-now-once>${where}</form>
356356 <form id="${id}-later" method="post" action="${post("later")}" hidden>${where}</form>`;
357357 }
src/web/studio/tell.ts +1 −1
@@ -452,7 +452,7 @@ async function studioTell(
452452 doneBy.length === 0
453453 ? null
454454 : card(
455 html`<form method="post" action="${thread}/state" class="row">
455 html`<form method="post" action="${thread}/state" class="row" data-now="Closed" data-now-once>
456456 <input type="hidden" name="state" value="closed" />
457457 <input type="hidden" name="reason" value="done" />
458458 <span
test/act-now.test.ts added +255
@@ -0,0 +1,255 @@
1 /**
2 * Actions that answer at once (#8708): the shared script's byte budget, the
3 * markup that opts a form in, the JSON refusal the script rolls back on, and
4 * a stream that resumes with only what it missed.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { openIssue } from "../src/actions/issues.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { ACT_NOW_BUDGET, actNowJs } from "../src/web/act_now.ts";
17 import { shellAsset } from "../src/web/asset.ts";
18 import { flowButton } from "../src/web/flow_button.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 describe("the script that makes actions answer at once", () => {
24 test("stays inside its byte budget", () => {
25 const bytes = new TextEncoder().encode(actNowJs).length;
26 expect(bytes).toBeLessThanOrEqual(ACT_NOW_BUDGET);
27 // The budget is tight, not a ceiling nobody will reach.
28 expect(bytes).toBeGreaterThan(ACT_NOW_BUDGET - 1200);
29 });
30
31 /** The script run against a stand-in page: one form, one button, one echo. */
32 const submit = async (reply: () => Promise<Response>) => {
33 const attrs: Record<string, string> = { "data-now": "Closed", "data-now-once": "", "data-now-into": "#echo", "data-now-from": "who" };
34 const label = { nodeType: 3, nodeValue: "Close" };
35 const aria: Record<string, string> = {};
36 const button = {
37 innerHTML: "<svg></svg>Close",
38 disabled: false,
39 childNodes: [{ nodeType: 1 }, label],
40 appendChild: () => {},
41 removeChild: () => {},
42 setAttribute: (n: string, v: string) => void (aria[n] = v),
43 removeAttribute: (n: string) => void delete aria[n],
44 };
45 const echo = { innerHTML: "bob", textContent: "bob" };
46 const note: { role: string; textContent: string } = { role: "", textContent: "" };
47 const select = { options: [{ text: " alice " }], selectedIndex: 0 };
48 const form = {
49 id: "",
50 getAttribute: (n: string) => attrs[n] ?? null,
51 hasAttribute: (n: string) => n in attrs,
52 setAttribute: (n: string, v: string) => void (attrs[n] = v),
53 querySelectorAll: (q: string) => (q === "button" ? [button] : []),
54 elements: { who: select } as Record<string, unknown>,
55 action: "/x",
56 };
57 let handler: (e: unknown) => void = () => {};
58 const doc = {
59 addEventListener: (_: string, h: (e: unknown) => void) => void (handler = h),
60 querySelector: (q: string) => (q === "#echo" ? echo : null),
61 createElement: () => note,
62 createTextNode: (t: string) => ({ nodeType: 3, nodeValue: t }),
63 };
64 Object.assign(note, { setAttribute: (_: string, v: string) => void (note.role = v) });
65 // The note is placed after the first button.
66 let noteInDom = false;
67 Object.assign(button, { insertAdjacentElement: () => void (noteInDom = true) });
68 const seen: { now: string; echo: string; aria: string | undefined; disabled: boolean; noteInDom: boolean; noteText: string }[] = [];
69 let prevented = false;
70 const fetcher = () => {
71 seen.push({ now: label.nodeValue, echo: echo.textContent, aria: aria["aria-disabled"], disabled: button.disabled, noteInDom, noteText: note.textContent });
72 return reply();
73 };
74 new Function("document", "window", "fetch", "FormData", "URLSearchParams", actNowJs)(
75 doc,
76 { fetch: fetcher },
77 fetcher,
78 class {
79 constructor() {
80 return [["who", "alice"]];
81 }
82 },
83 URLSearchParams,
84 );
85 handler({ target: form, defaultPrevented: false, preventDefault: () => (prevented = true) });
86 await Bun.sleep(150);
87 return { button, label, aria, echo, note, seen, prevented, attrs };
88 };
89
90 test("shows the result before the server has answered, and keeps it on success", async () => {
91 const run = await submit(async () => Response.json({ ok: true, flash: "Closed." }));
92 expect(run.prevented).toBe(true);
93 // At the moment of the post, the page already said the result.
94 expect(run.seen).toEqual([
95 { now: "Closed", echo: "alice", aria: "true", disabled: false, noteInDom: true, noteText: "" },
96 ]);
97 // The icon is kept; only the word changes.
98 expect(run.label.nodeValue).toBe("Closed");
99 expect(run.button.childNodes.length).toBe(2);
100 // Focus stays put: the button is never truly disabled, and stays so once done.
101 expect(run.button.disabled).toBe(false);
102 expect(run.aria["aria-disabled"]).toBe("true");
103 expect(run.echo.textContent).toBe("alice");
104 expect(run.note.role).toBe("status");
105 });
106
107 test("rolls back with the server's reason on refusal", async () => {
108 const run = await submit(async () => Response.json({ ok: false, error: "Only a writer can do that." }, { status: 422 }));
109 expect(run.button.innerHTML).toBe("<svg></svg>Close");
110 expect(run.aria["aria-disabled"]).toBeUndefined();
111 expect(run.echo.innerHTML).toBe("bob");
112 expect(run.note.textContent).toBe("Only a writer can do that.");
113 expect(run.note.role).toBe("alert");
114 });
115
116 test("rolls back when the forge cannot be reached", async () => {
117 const run = await submit(() => Promise.reject(new Error("offline")));
118 expect(run.button.innerHTML).toBe("<svg></svg>Close");
119 expect(run.note.textContent).toContain("Nothing changed");
120 });
121
122 test("rides the cached shell asset, not a script of its own", () => {
123 expect(shellAsset().code).toContain(actNowJs);
124 });
125
126 test("a flow button says the other word next", () => {
127 const markup = String(flowButton("/a/b/issues/1/flow", false, "/a/b"));
128 expect(markup).toContain('data-now="Not first"');
129 expect(markup).toContain('data-now-alt="Flow first"');
130 expect(markup).toContain('data-now-flip="flow"');
131 });
132 });
133
134 describe("a form that answers at once, and what the server says when it refuses", () => {
135 let dataDir = "";
136 let app: App;
137 let servers: Servers;
138 let alice: User;
139 let bob: User;
140 let cookie = "";
141 let reader = "";
142
143 beforeAll(async () => {
144 dataDir = await mkdtemp(join(tmpdir(), "forge-act-now-"));
145 app = createApp({ dataDir, port: 0 });
146 servers = await serve(app);
147 const make = async (username: string, admin: boolean) => {
148 const made = await createUser(app.db, {
149 origin: "seed",
150 trusted: true,
151 username,
152 email: `${username}@f.dev`,
153 password: "a-good-passphrase",
154 isAdmin: admin,
155 });
156 if (!made.ok) throw new Error(made.error);
157 return made.value;
158 };
159 alice = await make("alice", true);
160 bob = await make("bob", false);
161 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
162 reader = `forge_session=${createSession(app.db, bob.id).token}`;
163 const repo = await createRepo(app, alice, { name: "lab", visibility: "public" });
164 if (!repo.ok) throw new Error(String(repo.error));
165 const opened = openIssue(app, alice, repo.value, { title: "Do it", body: "now" });
166 if (!opened.ok) throw new Error(String(opened.error));
167 });
168
169 afterAll(async () => {
170 await servers.stop();
171 await rm(dataDir, { recursive: true, force: true });
172 });
173
174 const post = (who: string, path: string, form: Record<string, string>) =>
175 fetch(`${servers.url}${path}`, {
176 method: "POST",
177 headers: { cookie: who, origin: servers.url, accept: "application/json" },
178 body: new URLSearchParams(form),
179 redirect: "manual",
180 });
181
182 test("the thread page opts its assign, label and close forms in", async () => {
183 const page = await (await fetch(`${servers.url}/alice/lab/issues/1`, { headers: { cookie } })).text();
184 expect(page).toContain('data-now="Assigned" data-now-into="#assignee-now" data-now-from="assignee"');
185 expect(page).toContain('id="assignee-now"');
186 expect(page).toContain('data-now="Closed" data-now-once');
187 });
188
189 test("assign, close and flow answer in JSON when asked", async () => {
190 const assigned = await post(cookie, "/alice/lab/issues/1/assignee", { assignee: "alice" });
191 expect(assigned.status).toBe(200);
192 expect(await assigned.json()).toEqual({ ok: true, flash: "Assigned to alice." });
193 const flowed = await post(cookie, "/alice/lab/issues/1/flow", { flow: "1" });
194 expect(((await flowed.json()) as { ok: boolean }).ok).toBe(true);
195 const closed = await post(cookie, "/alice/lab/issues/1/state", { state: "closed" });
196 expect(await closed.json()).toEqual({ ok: true, flash: "Closed." });
197 });
198
199 test("a refusal is a 422 with the reason, and changes nothing", async () => {
200 const refused = await post(reader, "/alice/lab/issues/1/assignee", { assignee: "bob" });
201 expect(refused.status).toBe(422);
202 const body = (await refused.json()) as { ok: boolean; error: string };
203 expect(body.ok).toBe(false);
204 expect(body.error).toContain("write access");
205 const unknown = await post(cookie, "/alice/lab/issues/1/assignee", { assignee: "ghost" });
206 expect(unknown.status).toBe(422);
207 expect(((await unknown.json()) as { error: string }).error).toContain("no account named ghost");
208 });
209
210 test("without the JSON ask, a form still redirects", async () => {
211 const plain = await fetch(`${servers.url}/alice/lab/issues/1/assignee`, {
212 method: "POST",
213 headers: { cookie, origin: servers.url },
214 body: new URLSearchParams({ assignee: "" }),
215 redirect: "manual",
216 });
217 expect(plain.status).toBe(303);
218 });
219
220 test("a foreign origin is refused before anything happens", async () => {
221 const res = await fetch(`${servers.url}/alice/lab/issues/1/assignee`, {
222 method: "POST",
223 headers: { cookie, origin: "https://evil.example", accept: "application/json" },
224 body: new URLSearchParams({ assignee: "alice" }),
225 redirect: "manual",
226 });
227 expect(res.status).toBeGreaterThanOrEqual(400);
228 });
229
230 test("a stream resumed after an event gets what it missed and nothing before", async () => {
231 const repoId = getRepoId();
232 const ids = [1, 2, 3].map(
233 (n) => app.bus.append({ kind: "issue.opened", repoId, actorId: alice.id, payload: { number: 100 + n } }).id,
234 );
235 const res = await fetch(`${servers.url}/events`, { headers: { cookie, "last-event-id": ids[0]! } });
236 const reader2 = res.body!.getReader();
237 let text = "";
238 const deadline = Date.now() + 3_000;
239 while (!text.includes(`id: ${ids[2]}`) && Date.now() < deadline) {
240 const next = await Promise.race([reader2.read(), Bun.sleep(200).then(() => undefined)]);
241 if (next === undefined || next.done) continue;
242 text += new TextDecoder().decode(next.value);
243 }
244 await reader2.cancel();
245 expect(text).not.toContain(`id: ${ids[0]}`);
246 expect(text).toContain(`id: ${ids[1]}`);
247 expect(text).toContain(`id: ${ids[2]}`);
248 expect(text.indexOf(`id: ${ids[1]}`)).toBeLessThan(text.indexOf(`id: ${ids[2]}`));
249 });
250
251 function getRepoId(): string {
252 const row = app.db.query("select id from repo where name = 'lab'").get() as { id: string };
253 return row.id;
254 }
255 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs