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

Merged opened this

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

Merged

scout/forge:scout/3162-a-mirror-s-progress-page-follows-its-sta → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/3162-a-mirror-s-progress-page-follows-its-sta was merged into main as 63703f0.

Review

Sign in to review this change.
7 files changed +77 −25
docs/DESIGN.md +1 −1
@@ -178,7 +178,7 @@ specification.
178178 | **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` |
179179 | **live-browse banner** `liveBanner()` | `.live-note` | — | only on a live browse, above the content, one sentence | one accent line under the tab row |
180180 | **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 `●`/`◐`/`○` |
182182 | **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 |
183183 | **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 |
184184 | **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 */ `
124124 }
125125
126126 var source = new EventSource(url);
127 source.addEventListener('error', function () { /* the meta refresh, or the next event */ });
127 source.addEventListener('error', function () {

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.

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 });
128139 ['creating', 'fetching', 'labels', 'issues', 'pulls', 'comments', 'done', 'failed'].forEach(function (stage) {
129140 source.addEventListener('mirror.' + stage, function (event) {
130141 var job;
src/web/pages/browse.ts +11 −14
@@ -1031,10 +1031,9 @@ export function stageList(job: MirrorJob, streamUrl: string): Html {
10311031 /**
10321032 * GET /settings/origins/:id/mirror/:job — a mirror, while it happens.
10331033 *
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.
10381037 */
10391038 export function mirrorJobPage(ctx: Ctx): Response {
10401039 const denied = accountOnly(ctx);
@@ -1084,21 +1083,19 @@ export function mirrorJobPage(ctx: Ctx): Response {
10841083 </div>`
10851084 : html`<p class="muted small mt">
10861085 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>
10891091 </p>`;
10901092
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.
10941096 return page({
10951097 ctx,
10961098 title: `Mirroring ${job.fullName}`,
1097 ...(running
1098 ? { refresh: { seconds: 2 } }
1099 : job.stage === "done" && mirror !== undefined
1100 ? { refresh: { seconds: 1, url: mirror } }
1101 : {}),
11021099 body: html`<div class="page-head">
11031100 <h1>Mirroring ${job.fullName}</h1>
11041101 <span class="spacer"></span>
test/no-polling.test.ts −1
@@ -11,7 +11,6 @@ import { Glob } from "bun";
1111 const ALLOWED: Record<string, { allow: string[]; why: string }> = {
1212 "src/web/sw.ts": { allow: ['http-equiv="refresh"'], why: "the restart page: the forge is down, so there is no /events to hear" },
1313 "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" },
1514 "src/web/client.ts": { allow: ["data-live-every", "setInterval"], why: "the data-live-every mechanism itself, and clocks that only redraw text" },
1615 };
1716
test/refresh-meta.test.ts +13
@@ -29,4 +29,17 @@ describe("the refresh meta", () => {
2929 ).text();
3030 expect(client).not.toContain('meta[http-equiv="refresh"]');
3131 });
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 });
3245 });
test/web-browse.test.ts +6 −5
@@ -493,8 +493,9 @@ describe("browsing an origin repository on the web", () => {
493493 ]) {
494494 expect(running).toContain(stage);
495495 }
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>");
498499 // The enhancement is a static script under the strict CSP — the job it
499500 // watches arrives as a data attribute, never interpolated into the code.
500501 expect(running).toContain(
@@ -510,9 +511,9 @@ describe("browsing an origin repository on the web", () => {
510511 const after = await bodyOf(alice, jobPath);
511512 expect(after).toContain("Done");
512513 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"');
516517 });
517518
518519 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", () => {
251251 expect(live).toContain(
252252 `data-stream="/api/v1/user/origins/${id}/mirror/${running.id}/stream"`,
253253 );
254 expect(live).toMatch(/<meta[^>]*http-equiv="refresh"/);
254 expect(live).not.toContain("http-equiv");
255 expect(live).toContain(">Reload</a>");
255256 advance(running.id, {
256257 stage: "failed",
257258 detail: "",
@@ -315,11 +316,41 @@ describe("origins on the web", () => {
315316
316317 // Finished: no self-refresh loop, a redirect to the mirror instead.
317318 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");
320320 expect(after).toContain("Open the mirror");
321321 });
322322
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
323354 test("the repo header wears the where strip and the About rail one block", async () => {
324355 const html = await (await asUser(alice, "/alice/widgets")).text();
325356 // Three places, in order, with the workbench as the one being read.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs