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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8708-actions-answer-at-once-and-live-regions → main 3 commits fork 2 threads · 2 unresolved
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; | |
| 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 @@ | ||
| 10 | 10 | * behaviour that a page depends on to be usable. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { actNowJs } from "./act_now.ts"; | |
| 13 | 14 | import { imageViewJs } from "./render/image.ts"; |
| 14 | 15 | import { STAGE_BADGE_JS } from "./stage-row.ts"; |
| 15 | 16 | import { paletteHelpersJs } from "./palette.ts"; |
| @@ -1272,7 +1273,7 @@ export const editorJs = /* js */ ` | ||
| 1272 | 1273 | })(); |
| 1273 | 1274 | `; |
| 1274 | 1275 | |
| 1275 | export const shellJs = /* js */ ` | |
| 1276 | const shellCore = /* js */ ` | |
| 1276 | 1277 | (function () { |
| 1277 | 1278 | var doc = document; |
| 1278 | 1279 | ${modalJs} |
| @@ -2005,6 +2006,9 @@ export const shellJs = /* js */ ` | ||
| 2005 | 2006 | })(); |
| 2006 | 2007 | `; |
| 2007 | 2008 | |
| 2009 | /** The shell and the one script that makes actions answer at once (`act_now.ts`). */ | |
| 2010 | export const shellJs = `${shellCore}\n${actNowJs}`; | |
| 2011 | ||
| 2008 | 2012 | /** |
| 2009 | 2013 | * Live regions. A page marks the parts of itself that change while it is |
| 2010 | 2014 | * 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"; | ||
| 11 | 11 | const OFF = "Takes the flow label off: it no longer goes ahead of other ready work"; |
| 12 | 12 | |
| 13 | 13 | 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"> | |
| 15 | 15 | <input type="hidden" name="flow" value="${first ? "0" : "1"}" /> |
| 16 | 16 | <input type="hidden" name="back" value="${back}" /> |
| 17 | 17 | ${btn(first ? "Not first" : "Flow first", { size: "sm", variant: "outline" })} |
src/web/pages/pull.ts +1 −1
| @@ -452,7 +452,7 @@ function mergeBox( | ||
| 452 | 452 | // A reader's head runs nothing until a writer approves it; say so rather |
| 453 | 453 | // than leave "no checks yet" waiting for something that will not come. |
| 454 | 454 | 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> | |
| 456 | 456 | <input type="hidden" name="state" value="open" /> |
| 457 | 457 | ${btn("Reopen", { size: "sm", icon: "pull" })} |
| 458 | 458 | </form>`; |
src/web/pages/thread.ts +15 −15
| @@ -521,7 +521,7 @@ export function composer(ctx: Ctx, o: ComposerOptions): Html { | ||
| 521 | 521 | // action. The state form sits outside; the button joins it by id. |
| 522 | 522 | const stateForm = |
| 523 | 523 | 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> | |
| 525 | 525 | <input |
| 526 | 526 | type="hidden" |
| 527 | 527 | name="state" |
| @@ -633,8 +633,8 @@ export function labelsCard( | ||
| 633 | 633 | const mayEdit = canWrite(ctx.app.db, repo, ctx.auth.user); |
| 634 | 634 | const chips = |
| 635 | 635 | 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"> | |
| 638 | 638 | ${applied.map((l) => |
| 639 | 639 | labelChip( |
| 640 | 640 | l, |
| @@ -651,7 +651,7 @@ export function labelsCard( | ||
| 651 | 651 | const applies = new Set(applied.map((l) => l.id)); |
| 652 | 652 | return card( |
| 653 | 653 | 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"> | |
| 655 | 655 | <fieldset class="stack"> |
| 656 | 656 | <legend class="sr-only">Labels on this thread</legend> |
| 657 | 657 | ${all.map( |
| @@ -729,8 +729,8 @@ export function assigneeCard( | ||
| 729 | 729 | const mayEdit = canWrite(ctx.app.db, repo, ctx.auth.user); |
| 730 | 730 | const current = |
| 731 | 731 | 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"> | |
| 734 | 734 | ${when(issue.assigneeIsAgent, () => icon("bot"))} |
| 735 | 735 | ${userLink(issue.assigneeName, { |
| 736 | 736 | mark: marksByName(ctx.app.db, [issue.assigneeName]).get( |
| @@ -746,7 +746,7 @@ export function assigneeCard( | ||
| 746 | 746 | const candidates = assigneeCandidates(ctx, repo, issue); |
| 747 | 747 | return card( |
| 748 | 748 | 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"> | |
| 750 | 750 | <label class="sr-only" for="f-assignee">Assign to</label> |
| 751 | 751 | <select class="input" id="f-assignee" name="assignee"> |
| 752 | 752 | <option |
| @@ -789,7 +789,7 @@ export async function assigneeSubmit( | ||
| 789 | 789 | const name = field(form, "assignee").trim(); |
| 790 | 790 | const assignee = name === "" ? null : getUserByName(ctx.app.db, name); |
| 791 | 791 | if (assignee === undefined) |
| 792 | return flashTo(ctx, dest, { | |
| 792 | return flashOrJson(ctx, dest, { | |
| 793 | 793 | tone: "danger", |
| 794 | 794 | message: `There is no account named ${name}.`, |
| 795 | 795 | }); |
| @@ -807,12 +807,12 @@ export async function assigneeSubmit( | ||
| 807 | 807 | forbidden: "You need write access to assign.", |
| 808 | 808 | archived: ARCHIVED_WHY, |
| 809 | 809 | }; |
| 810 | return flashTo(ctx, dest, { | |
| 810 | return flashOrJson(ctx, dest, { | |
| 811 | 811 | tone: "danger", |
| 812 | 812 | message: words[assigned.error] ?? "That could not be assigned.", |
| 813 | 813 | }); |
| 814 | 814 | } |
| 815 | return flashTo(ctx, dest, { | |
| 815 | return flashOrJson(ctx, dest, { | |
| 816 | 816 | message: |
| 817 | 817 | assignee === null |
| 818 | 818 | ? "Unassigned." |
| @@ -951,7 +951,7 @@ export async function labelsSubmit( | ||
| 951 | 951 | chosen, |
| 952 | 952 | ); |
| 953 | 953 | if (!applied.ok) { |
| 954 | return flashTo(ctx, dest, { | |
| 954 | return flashOrJson(ctx, dest, { | |
| 955 | 955 | tone: "danger", |
| 956 | 956 | message: |
| 957 | 957 | originError(applied.error) ?? |
| @@ -960,7 +960,7 @@ export async function labelsSubmit( | ||
| 960 | 960 | : THREAD_SAYS.labelsForbidden), |
| 961 | 961 | }); |
| 962 | 962 | } |
| 963 | return flashTo(ctx, dest, { | |
| 963 | return flashOrJson(ctx, dest, { | |
| 964 | 964 | message: |
| 965 | 965 | applied.value.length === 0 |
| 966 | 966 | ? "Labels cleared." |
| @@ -977,14 +977,14 @@ export async function flowSubmit(ctx: Ctx): Promise<Response> { | ||
| 977 | 977 | if (form instanceof Response) return form; |
| 978 | 978 | const asked = field(form, "flow"); |
| 979 | 979 | 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 }); | |
| 981 | 981 | const applied = await putFlowFirst(ctx.app, ctx.auth.user, found.repo, found.issue, asked === "1"); |
| 982 | 982 | if (!applied.ok) |
| 983 | return flashTo(ctx, dest, { | |
| 983 | return flashOrJson(ctx, dest, { | |
| 984 | 984 | tone: "danger", |
| 985 | 985 | message: originError(applied.error) ?? THREAD_SAYS.labelsForbidden, |
| 986 | 986 | }); |
| 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." }); | |
| 988 | 988 | } |
| 989 | 989 | |
| 990 | 990 | // ---- teams ---- |
src/web/studio/asks_page.ts +6 −3
| @@ -31,7 +31,8 @@ import { onYouAnswer, onYouRegion } from "./home.ts"; | ||
| 31 | 31 | import { liveRegionsJs } from "../client.ts"; |
| 32 | 32 | import { liveClientJs } from "../live.ts"; |
| 33 | 33 | 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"; | |
| 35 | 36 | import { MAX_BODY } from "../../core/issues.ts"; |
| 36 | 37 | import { issueTitles } from "../../core/proposals.ts"; |
| 37 | 38 | import { actOnProposal, type ProposalVerb } from "../../actions/proposals.ts"; |
| @@ -760,8 +761,10 @@ export function asksProposal(verb: ProposalVerb) { | ||
| 760 | 761 | ? notFoundPage(ctx) |
| 761 | 762 | : done.error.code === "forbidden" || done.error.code === "unauthorized" |
| 762 | 763 | ? 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")); | |
| 765 | 768 | }; |
| 766 | 769 | } |
| 767 | 770 |
src/web/studio/decisions.ts +1 −1
| @@ -352,6 +352,6 @@ function proposalForms(d: Decision, back?: string): Html { | ||
| 352 | 352 | const id = askFormId(d); |
| 353 | 353 | const post = (verb: string) => `/asks/proposal/${d.proposal!.id}/${verb}`; |
| 354 | 354 | 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> | |
| 356 | 356 | <form id="${id}-later" method="post" action="${post("later")}" hidden>${where}</form>`; |
| 357 | 357 | } |
src/web/studio/tell.ts +1 −1
| @@ -452,7 +452,7 @@ async function studioTell( | ||
| 452 | 452 | doneBy.length === 0 |
| 453 | 453 | ? null |
| 454 | 454 | : 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> | |
| 456 | 456 | <input type="hidden" name="state" value="closed" /> |
| 457 | 457 | <input type="hidden" name="reason" value="done" /> |
| 458 | 458 | <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 | }); |
new above
offline
Disabling the focused button drops keyboard focus. Use aria-disabled and swallow the click, or restore focus on rollback.