Public: anyone can read this; the crew can change it.
A mirror's progress page follows its stages over the stream, with no meta refresh #3175
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3162-a-mirror-s-progress-page-follows-its-sta → main 5 commits fork 2 threads · 2 unresolved
7 files
changed +77
−25
docs/DESIGN.md +1 −1
| @@ -178,7 +178,7 @@ specification. | ||
| 178 | 178 | | **where strip** `whereStrip()` (`scope.ts`) | `.where > .where-place` | `data-state=reading\|present\|absent` — accent dot, success dot, faint dot | three places, always in this order and these words; a place is a link where one exists, and the third is the pin toggle | `● codeberg.org · ● workbench · ◆ this machine` | |
| 179 | 179 | | **live-browse banner** `liveBanner()` | `.live-note` | — | only on a live browse, above the content, one sentence | one accent line under the tab row | |
| 180 | 180 | | **where-it-lives block** | `.where-block` | — | origin, last fetch, Sync now and where pushes land, as one block in the About rail | the same four lines under a rule | |
| 181 | | **mirror stages** `stageList()` | `.stages > li` | `data-state=done\|now\|failed\|todo` | the stage order is the markup order; a script updates it in place and a `<meta refresh>` does it without one | a checklist with `●`/`◐`/`○` | | |
| 181 | | **mirror stages** `stageList()` | `.stages > li` | `data-state=done\|now\|failed\|todo` | the stage order is the markup order; a script updates it in place and goes to the mirror on done; without JavaScript the same markup renders and a `<noscript>` Reload link re-reads it. Nothing here refreshes itself: no `<meta refresh>`, no timer | a checklist with `●`/`◐`/`○` | | |
| 182 | 182 | | **origin chip** (`chrome.ts`) | `.repo-origin > .origin-chip` | the forge's mark: `github` / `forgejo` / `gitea` | the older one-line form, still used off the repo header | one `mirrored from owner/name @ host` fact line | |
| 183 | 183 | | **origin state badge** `badge()` | `.badge` | `success` healthy · `danger` needs re-auth · `attn` error | the error message sits under the row, never inside the badge | the same three words beside the row | |
| 184 | 184 | | **imported author** `originTag()` | `.byline.origin-by` | the origin's mark instead of an avatar | never a link to `/name` — that would be a different person | `▲ login` | |
src/web/client.ts +12 −1
| @@ -124,7 +124,18 @@ export const mirrorJs = /* js */ ` | ||
| 124 | 124 | } |
| 125 | 125 | |
| 126 | 126 | var source = new EventSource(url); |
| 127 | source.addEventListener('error', function () { /* the meta refresh, or the next event */ }); | |
| 127 | source.addEventListener('error', function () { | |
When the guard declines to reload (key already set, or no storage) the page stays silent on a dead stream. Add a one-line status in the stage list saying the stream stopped and to use Reload. |
||
| 128 | // EventSource reconnects by itself; once it has closed for good (the job | |
| 129 | // expired, or the stream is gone) the page re-reads itself, once per | |
| 130 | // stream url. The key is set before the reload, so a stream that fails | |
outdated
Unbounded: every reload that fails the same way reloads again. Guard so it happens once per stream url, e.g. if (source.readyState === 2 && !sessionStorage.getItem(url)) { sessionStorage.setItem(url, 1); location.reload(); } |
||
| 131 | // the same way again cannot loop; with no storage, there is no reload. | |
| 132 | if (source.readyState !== 2) return; | |
| 133 | try { | |
| 134 | if (sessionStorage.getItem(url)) return; | |
| 135 | sessionStorage.setItem(url, '1'); | |
| 136 | } catch (err) { return; } | |
| 137 | location.reload(); | |
| 138 | }); | |
| 128 | 139 | ['creating', 'fetching', 'labels', 'issues', 'pulls', 'comments', 'done', 'failed'].forEach(function (stage) { |
| 129 | 140 | source.addEventListener('mirror.' + stage, function (event) { |
| 130 | 141 | var job; |
src/web/pages/browse.ts +11 −14
| @@ -1031,10 +1031,9 @@ export function stageList(job: MirrorJob, streamUrl: string): Html { | ||
| 1031 | 1031 | /** |
| 1032 | 1032 | * GET /settings/origins/:id/mirror/:job — a mirror, while it happens. |
| 1033 | 1033 | * |
| 1034 | * With no JavaScript this is a page that refreshes itself every two seconds | |
| 1035 | * and stops when the job stops. With JavaScript it subscribes to the job's | |
| 1036 | * own event stream and the checklist moves without a reload. Both render the | |
| 1037 | * same markup, and the page is correct if the script never arrives. | |
| 1034 | * With JavaScript it subscribes to the job's own event stream, the checklist | |
| 1035 | * moves without a reload, and it goes to the mirror when the job is done. | |
| 1036 | * Without, the same markup says to Reload; there is no meta refresh. | |
| 1038 | 1037 | */ |
| 1039 | 1038 | export function mirrorJobPage(ctx: Ctx): Response { |
| 1040 | 1039 | const denied = accountOnly(ctx); |
| @@ -1084,21 +1083,19 @@ export function mirrorJobPage(ctx: Ctx): Response { | ||
| 1084 | 1083 | </div>` |
| 1085 | 1084 | : html`<p class="muted small mt"> |
| 1086 | 1085 | This keeps going if you close the page — come back to this link and |
| 1087 | it will say where it got to. No JavaScript? This page refreshes | |
| 1088 | itself every couple of seconds. | |
| 1086 | it will say where it got to. | |
| 1087 | <noscript> | |
| 1088 | Without JavaScript this page does not move by itself: | |
| 1089 | <a href="${ctx.url.pathname}">Reload</a> to see where it is. | |
| 1090 | </noscript> | |
| 1089 | 1091 | </p>`; |
| 1090 | 1092 | |
| 1091 | // Only a job still going refreshes or streams: a finished one is a page | |
| 1092 | // that stands, a failed one a page with the reason and a way to retry — | |
| 1093 | // never one that reloads itself over and over on the same news. | |
| 1093 | // Only a job still going streams: a finished one is a page that stands, a | |
| 1094 | // failed one a page with the reason and a way to retry. No page here | |
| 1095 | // refreshes itself; the script follows the stream and goes to the mirror. | |
| 1094 | 1096 | return page({ |
| 1095 | 1097 | ctx, |
| 1096 | 1098 | title: `Mirroring ${job.fullName}`, |
| 1097 | ...(running | |
| 1098 | ? { refresh: { seconds: 2 } } | |
| 1099 | : job.stage === "done" && mirror !== undefined | |
| 1100 | ? { refresh: { seconds: 1, url: mirror } } | |
| 1101 | : {}), | |
| 1102 | 1099 | body: html`<div class="page-head"> |
| 1103 | 1100 | <h1>Mirroring ${job.fullName}</h1> |
| 1104 | 1101 | <span class="spacer"></span> |
test/no-polling.test.ts −1
| @@ -11,7 +11,6 @@ import { Glob } from "bun"; | ||
| 11 | 11 | const ALLOWED: Record<string, { allow: string[]; why: string }> = { |
| 12 | 12 | "src/web/sw.ts": { allow: ['http-equiv="refresh"'], why: "the restart page: the forge is down, so there is no /events to hear" }, |
| 13 | 13 | "src/web/layout.ts": { allow: ['http-equiv="refresh"'], why: "defines the refresh option and its meta tag; callers are listed here" }, |
| 14 | "src/web/pages/browse.ts": { allow: ["refresh: {"], why: "mirror progress runs in a process that emits no events" }, | |
| 15 | 14 | "src/web/client.ts": { allow: ["data-live-every", "setInterval"], why: "the data-live-every mechanism itself, and clocks that only redraw text" }, |
| 16 | 15 | }; |
| 17 | 16 |
test/refresh-meta.test.ts +13
| @@ -29,4 +29,17 @@ describe("the refresh meta", () => { | ||
| 29 | 29 | ).text(); |
| 30 | 30 | expect(client).not.toContain('meta[http-equiv="refresh"]'); |
| 31 | 31 | }); |
| 32 | ||
| 33 | test("a closed mirror stream reloads the page at most once per stream url", async () => { | |
| 34 | const { mirrorJs } = await import("../src/web/client.ts"); | |
| 35 | const at = mirrorJs.indexOf("location.reload()"); | |
| 36 | expect(at).toBeGreaterThan(-1); | |
| 37 | const before = mirrorJs.slice(0, at); | |
| 38 | // The guard is read and then set, both ahead of the one reload. | |
| 39 | expect(before).toContain("sessionStorage.getItem(url)"); | |
| 40 | expect(before.indexOf("sessionStorage.getItem(url)")).toBeLessThan( | |
| 41 | before.indexOf("sessionStorage.setItem(url"), | |
| 42 | ); | |
| 43 | expect(mirrorJs.match(/location\.reload\(\)/g)).toHaveLength(1); | |
| 44 | }); | |
| 32 | 45 | }); |
test/web-browse.test.ts +6 −5
| @@ -493,8 +493,9 @@ describe("browsing an origin repository on the web", () => { | ||
| 493 | 493 | ]) { |
| 494 | 494 | expect(running).toContain(stage); |
| 495 | 495 | } |
| 496 | // The no-JavaScript half: it refreshes itself while the job runs. | |
| 497 | expect(running).toContain('http-equiv="refresh"'); | |
| 496 | // The no-JavaScript half: a plain Reload link, never a meta refresh. | |
| 497 | expect(running).not.toContain("http-equiv"); | |
| 498 | expect(running).toContain(">Reload</a>"); | |
| 498 | 499 | // The enhancement is a static script under the strict CSP — the job it |
| 499 | 500 | // watches arrives as a data attribute, never interpolated into the code. |
| 500 | 501 | expect(running).toContain( |
| @@ -510,9 +511,9 @@ describe("browsing an origin repository on the web", () => { | ||
| 510 | 511 | const after = await bodyOf(alice, jobPath); |
| 511 | 512 | expect(after).toContain("Done"); |
| 512 | 513 | expect(after).toContain("Open the mirror"); |
| 513 | // Finished: no two-second self-refresh, a hop to the mirror instead. | |
| 514 | expect(after).toContain("url=/alice/widgets"); | |
| 515 | expect(after).not.toContain('content="2"'); | |
| 514 | // Finished: a page that stands, with a link to the mirror. | |
| 515 | expect(after).not.toContain("http-equiv"); | |
| 516 | expect(after).toContain('href="/alice/widgets"'); | |
| 516 | 517 | }); |
| 517 | 518 | |
| 518 | 519 | test("after the mirror, the workbench is where the page reads from", async () => { |
test/web-origins.test.ts +34 −3
| @@ -251,7 +251,8 @@ describe("origins on the web", () => { | ||
| 251 | 251 | expect(live).toContain( |
| 252 | 252 | `data-stream="/api/v1/user/origins/${id}/mirror/${running.id}/stream"`, |
| 253 | 253 | ); |
| 254 | expect(live).toMatch(/<meta[^>]*http-equiv="refresh"/); | |
| 254 | expect(live).not.toContain("http-equiv"); | |
| 255 | expect(live).toContain(">Reload</a>"); | |
| 255 | 256 | advance(running.id, { |
| 256 | 257 | stage: "failed", |
| 257 | 258 | detail: "", |
| @@ -315,11 +316,41 @@ describe("origins on the web", () => { | ||
| 315 | 316 | |
| 316 | 317 | // Finished: no self-refresh loop, a redirect to the mirror instead. |
| 317 | 318 | const after = await (await asUser(alice, jobPath)).text(); |
| 318 | expect(after).toContain("url=/alice/widgets"); | |
| 319 | expect(after).not.toContain('content="2"'); | |
| 319 | expect(after).not.toContain("http-equiv"); | |
| 320 | 320 | expect(after).toContain("Open the mirror"); |
| 321 | 321 | }); |
| 322 | 322 | |
| 323 | test("a running job's stream sends the next stage, then done with the mirror's address; only its starter may read it", async () => { | |
| 324 | const id = listOrigins(app.db, getUserByName(app.db, "alice")!.id)[0]!.id; | |
| 325 | const job = createJob(id, "octo/streamed"); | |
| 326 | const path = `/api/v1/user/origins/${id}/mirror/${job.id}/stream`; | |
| 327 | expect((await asUser(bob, path)).status).toBe(404); | |
| 328 | expect((await asUser("", path)).status).toBeGreaterThanOrEqual(401); | |
| 329 | ||
| 330 | const response = await asUser(alice, path); | |
| 331 | expect(response.status).toBe(200); | |
| 332 | expect(response.headers.get("content-type")).toContain("text/event-stream"); | |
| 333 | const reader = response.body!.getReader(); | |
| 334 | const decoder = new TextDecoder(); | |
| 335 | let seen = ""; | |
| 336 | const until = async (needle: string) => { | |
| 337 | while (!seen.includes(needle)) { | |
| 338 | const { value, done } = await reader.read(); | |
| 339 | if (done) break; | |
| 340 | seen += decoder.decode(value); | |
| 341 | } | |
| 342 | }; | |
| 343 | await until("event: mirror.creating"); | |
| 344 | advance(job.id, { stage: "fetching", detail: "objects" }); | |
| 345 | await until("event: mirror.fetching"); | |
| 346 | advance(job.id, { stage: "done", detail: "", repo: getRepo(app.db, "alice", "widgets")! }); | |
| 347 | await until("event: mirror.done"); | |
| 348 | await reader.cancel(); | |
| 349 | expect(seen).toContain("event: mirror.fetching"); | |
| 350 | expect(seen).toContain('"name":"widgets"'); | |
| 351 | expect(seen.indexOf("mirror.fetching")).toBeLessThan(seen.indexOf("mirror.done")); | |
| 352 | }); | |
| 353 | ||
| 323 | 354 | test("the repo header wears the where strip and the About rail one block", async () => { |
| 324 | 355 | const html = await (await asUser(alice, "/alice/widgets")).text(); |
| 325 | 356 | // Three places, in order, with the workbench as the one being read. |
new above
offline
The error handler is correct, but its test only checks the order of strings in the script. A jsdom-free test that stubs EventSource and sessionStorage and fires the error would catch a regression that string order cannot.