Skip to content

Commit b22de54

Browse files
feat(dashboard): testable batch panel — extract to ES module + Vitest (#68)
* docs(specs): dashboard-js-testing — Phase 1 requirements (draft) Spec to make the Cowork dashboard's inline-template JS testable: extract pure logic into no-build ES modules under static_cw/, cover with the existing Vitest harness (currently scoped to editor-frontend only), and wire a root-level CI job. Batch panel (attune-gui#67) is the reference implementation; remaining 7 inline script blocks follow incrementally. Decisions baked in (overridable at review): plain ES modules / no build step; pure-logic-first test depth; batch-panel-as-reference scope. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(specs): dashboard-js-testing — Phase 2 design (draft) Pattern: pure logic → plain ES module in static_cw/ (served as-is, no build); inline <script> shrinks to a DOM-glue shim that imports it; Vitest tests the pure module directly. Test runner: extend editor-frontend's existing Vitest project to include ../sidecar/.../static_cw/**/*.test.js (reuses the green frontend CI job, no new node_modules/toolchain) — chosen over a separate root project. Also corrects a Phase 1 inaccuracy: there is NO root package.json; vitest lives only in editor-frontend. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(specs): dashboard-js-testing — Phase 3 tasks (approved) 8 ordered tasks: extract batch-panel pure logic to a no-build ES module, 7 Vitest cases, extend editor-frontend's include glob, rewrite the inline panel as a type=module glue shim, verify (vitest + live), pattern doc. Phases 1-3 all approved 2026-06-14. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(dashboard): extract batch panel to a tested ES module Make the Living Docs batch-progress panel's logic testable without a build step (specs/dashboard-js-testing): - static_cw/batch-panel.js — pure isTerminal / batchView / shouldClose, DOM-free (loads as a native ES module in the browser, imports in Node for Vitest). Logic lifted verbatim from the former inline IIFE. - static_cw/batch-panel.test.js — 7 cases incl. the reconnect-suppression invariant and a divide-by-zero guard. - editor-frontend/vitest.config.ts — include static_cw/**/*.test.js so the existing frontend (Vitest) CI job covers dashboard modules too (one project, no new node_modules/job). - living_docs.html — inline panel script becomes a <script type="module"> glue shim that imports the module; behavior-preserving. - static_cw/README.md — the "testing dashboard JS" pattern for migrating the remaining inline blocks. Vitest: 107 passed (editor + dashboard). Verified live: module 200, SSE 200, panel hidden on no-batch, no console errors — identical to before. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9205ca5 commit b22de54

8 files changed

Lines changed: 641 additions & 36 deletions

File tree

editor-frontend/vitest.config.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,16 @@ import { defineConfig } from "vitest/config";
33
// Unit tests live next to source. The e2e specs under `e2e/` use
44
// `@playwright/test` syntax, so they're explicitly excluded here and
55
// run via `npm run e2e` instead.
6+
//
7+
// This project also covers the Cowork dashboard's no-build ES modules
8+
// served from sidecar/attune_gui/static_cw/ — one Vitest project + one
9+
// CI job for all attune-gui frontend JS (editor + dashboard).
610
export default defineConfig({
711
test: {
8-
include: ["src/**/*.test.ts"],
12+
include: [
13+
"src/**/*.test.ts",
14+
"../sidecar/attune_gui/static_cw/**/*.test.js",
15+
],
916
exclude: ["node_modules", "e2e"],
1017
},
1118
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Cowork dashboard JS — `static_cw/`
2+
3+
Static assets for the Cowork dashboard, served as-is at `/cw-static/*`
4+
(mounted in `app.py`). **No build step, no bundler** — the `.js` files
5+
here are both the source and the served asset.
6+
7+
## Testing dashboard JS
8+
9+
Dashboard interactivity used to live as inline `<script>` blocks in Jinja
10+
templates, which can't be unit-tested. The pattern below makes it
11+
testable without adding a build step. `batch-panel.js` is the reference
12+
implementation (see `specs/dashboard-js-testing/`).
13+
14+
**The rule: pure logic goes in a module here; the template keeps only a
15+
DOM-glue shim.**
16+
17+
1. **Extract pure functions** into a `*.js` module in this directory —
18+
DOM-free and `window`-free (so Vitest imports it in Node, no jsdom).
19+
A frame/event in, a plain view-model or decision out. All the
20+
branching lives here.
21+
22+
```js
23+
// my-panel.js
24+
export function myView(frame) { /* → { visible, label, … } */ }
25+
```
26+
27+
2. **Test it** in a colocated `*.test.js` with Vitest:
28+
29+
```js
30+
import { myView } from "./my-panel.js";
31+
it("hides on empty", () => expect(myView({state:"none"})).toEqual({visible:false}));
32+
```
33+
34+
3. **Shrink the template** to a `<script type="module">` that imports the
35+
module and only touches the DOM — no branching worth testing:
36+
37+
```html
38+
<script type="module">
39+
import { myView } from '/cw-static/my-panel.js';
40+
const els = { /* querySelector */ };
41+
function apply(v) { /* set hidden / textContent / styles from v */ }
42+
// …wire events, call apply(myView(frame))…
43+
</script>
44+
```
45+
46+
## Running the tests
47+
48+
The dashboard modules are covered by the **editor-frontend** Vitest
49+
project (one project, one CI job for all attune-gui frontend JS). Its
50+
`vitest.config.ts` includes `../sidecar/attune_gui/static_cw/**/*.test.js`.
51+
52+
```bash
53+
cd editor-frontend && npm test # runs editor + dashboard suites
54+
```
55+
56+
The `frontend (Vitest)` CI job runs these on every PR.
57+
58+
## Migrating the remaining inline blocks
59+
60+
Several dashboard templates still carry inline `<script>` logic (the
61+
living-docs poller, command runner, etc.). Migrate them to this pattern
62+
opportunistically — extract the pure half, test it, leave a glue shim.
63+
Keep each migration behavior-preserving (verify the page renders
64+
identically).
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Pure logic for the Living Docs "Batch progress" panel.
2+
//
3+
// DOM-free and window-free on purpose: the browser loads this as a native
4+
// ES module (`<script type="module">`), and Vitest imports it directly in
5+
// Node — no jsdom needed. All DOM wiring (EventSource, querySelector,
6+
// applying the view) lives in the inline shim in living_docs.html.
7+
//
8+
// Consumes the frame shape emitted by GET /api/batch/status/stream
9+
// (see specs/gui-batch-status-sse): {state, processing_status,
10+
// request_count, request_counts{...}, ended_at, batch_id, detail}.
11+
12+
/** processing_status values (and a set ended_at) that mean the batch stopped. */
13+
export const TERMINAL = ["ended", "canceled", "expired"];
14+
15+
/** Has the batch reached a stop state? */
16+
export function isTerminal(frame) {
17+
return TERMINAL.includes(frame.processing_status) || Boolean(frame.ended_at);
18+
}
19+
20+
/**
21+
* Map an SSE frame to a flat view model the DOM shim applies verbatim.
22+
*
23+
* Returns:
24+
* { visible: false } // hide the panel
25+
* { visible: true, label, counts, detail, pct } // render
26+
* `pct` is `null` for the error state (leave the progress bar untouched);
27+
* a number 0..100 otherwise.
28+
*/
29+
export function batchView(frame) {
30+
if (frame.state === "none") return { visible: false };
31+
if (frame.state === "error") {
32+
return {
33+
visible: true,
34+
label: "Status unavailable",
35+
counts: "",
36+
detail: frame.detail || "retry",
37+
pct: null,
38+
};
39+
}
40+
const c = frame.request_counts || {};
41+
const total = frame.request_count || 0;
42+
const done =
43+
(c.succeeded || 0) + (c.errored || 0) + (c.canceled || 0) + (c.expired || 0);
44+
return {
45+
visible: true,
46+
pct: total ? Math.round((done / total) * 100) : 0,
47+
counts: total ? `${done}/${total}` : "",
48+
label: isTerminal(frame)
49+
? `Completed — ${c.succeeded || 0} succeeded, ${c.errored || 0} errored`
50+
: frame.processing_status || "processing",
51+
detail: frame.batch_id ? `batch ${frame.batch_id}` : "",
52+
};
53+
}
54+
55+
/**
56+
* Should the client close the EventSource (and suppress the browser's
57+
* auto-reconnect)? True once the server has sent its last frame for this
58+
* stream: a none/error frame, or any terminal pending frame.
59+
*/
60+
export function shouldClose(frame) {
61+
return frame.state !== "pending" || isTerminal(frame);
62+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { batchView, isTerminal, shouldClose } from "./batch-panel.js";
4+
5+
const pending = (over = {}) => ({
6+
state: "pending",
7+
processing_status: "in_progress",
8+
request_count: 10,
9+
request_counts: { succeeded: 3, errored: 0, canceled: 0, expired: 0, processing: 7 },
10+
ended_at: null,
11+
batch_id: "msgbatch_123",
12+
...over,
13+
});
14+
15+
describe("batchView", () => {
16+
it("hides the panel for the none state", () => {
17+
expect(batchView({ state: "none" })).toEqual({ visible: false });
18+
});
19+
20+
it("shows an error frame with detail passed through, bar untouched", () => {
21+
const v = batchView({ state: "error", detail: "boom" });
22+
expect(v).toMatchObject({ visible: true, label: "Status unavailable", counts: "", detail: "boom" });
23+
expect(v.pct).toBeNull();
24+
});
25+
26+
it("falls back to 'retry' when an error frame has no detail", () => {
27+
expect(batchView({ state: "error" }).detail).toBe("retry");
28+
});
29+
30+
it("renders a pending frame: pct, counts, status label, batch detail", () => {
31+
const v = batchView(pending());
32+
expect(v).toMatchObject({
33+
visible: true,
34+
pct: 30, // (3 done / 10 total)
35+
counts: "3/10",
36+
label: "in_progress",
37+
detail: "batch msgbatch_123",
38+
});
39+
});
40+
41+
it("renders a terminal frame as a completion summary", () => {
42+
const v = batchView(
43+
pending({
44+
processing_status: "ended",
45+
ended_at: "2026-06-14T12:00:00Z",
46+
request_counts: { succeeded: 9, errored: 1, canceled: 0, expired: 0 },
47+
}),
48+
);
49+
expect(v.label).toBe("Completed — 9 succeeded, 1 errored");
50+
expect(v.pct).toBe(100); // (9 + 1) / 10
51+
});
52+
53+
it("guards against divide-by-zero: total 0 → pct 0, not NaN", () => {
54+
const v = batchView(pending({ request_count: 0, request_counts: {} }));
55+
expect(v.pct).toBe(0);
56+
expect(v.counts).toBe("");
57+
});
58+
});
59+
60+
describe("isTerminal", () => {
61+
it.each(["ended", "canceled", "expired"])("is true for processing_status %s", (s) => {
62+
expect(isTerminal({ processing_status: s })).toBe(true);
63+
});
64+
65+
it("is true when ended_at is set regardless of status", () => {
66+
expect(isTerminal({ processing_status: "in_progress", ended_at: "2026-06-14T12:00:00Z" })).toBe(true);
67+
});
68+
69+
it("is false for an in-progress frame", () => {
70+
expect(isTerminal({ processing_status: "in_progress", ended_at: null })).toBe(false);
71+
});
72+
});
73+
74+
describe("shouldClose", () => {
75+
it("closes on none and error frames", () => {
76+
expect(shouldClose({ state: "none" })).toBe(true);
77+
expect(shouldClose({ state: "error" })).toBe(true);
78+
});
79+
80+
it("closes on a terminal pending frame", () => {
81+
expect(shouldClose(pending({ processing_status: "ended" }))).toBe(true);
82+
});
83+
84+
it("keeps the stream open on a non-terminal pending frame", () => {
85+
// The reconnect-suppression invariant: must NOT close here, or the
86+
// panel would never receive subsequent progress frames.
87+
expect(shouldClose(pending())).toBe(false);
88+
});
89+
});

sidecar/attune_gui/templates/living_docs.html

Lines changed: 22 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -358,54 +358,41 @@ <h2 class="section-title">Documents <span class="dim small">({{ rows|length }})<
358358
}
359359
});
360360

361-
// ── Batch progress (SSE, observe-only) ────────────────────────────────────
362-
// Streams GET /api/batch/status/stream. The server sends one frame per poll
363-
// and closes after a none/error/terminal frame; we es.close() on those so the
364-
// browser does not auto-reconnect into a none → close → reconnect loop. New
365-
// batches started later are picked up on the next page load.
366-
(function batchProgress() {
367-
const section = document.getElementById('batch-section');
368-
if (!section || typeof EventSource === 'undefined') return;
361+
</script>
362+
363+
<!-- Batch progress (SSE, observe-only). Pure logic — batchView/shouldClose —
364+
lives in the tested module static_cw/batch-panel.js; this shim only wires
365+
EventSource + DOM. See specs/dashboard-js-testing. -->
366+
<script type="module">
367+
import { batchView, shouldClose } from '/cw-static/batch-panel.js';
368+
369+
const section = document.getElementById('batch-section');
370+
if (section && typeof EventSource !== 'undefined') {
369371
const label = document.getElementById('batch-label');
370372
const counts = document.getElementById('batch-counts');
371373
const fill = document.getElementById('batch-fill');
372374
const detail = document.getElementById('batch-detail');
373375

374-
const TERMINAL = ['ended', 'canceled', 'expired'];
375-
const isTerminal = (f) => TERMINAL.includes(f.processing_status) || !!f.ended_at;
376-
377-
function render(frame) {
378-
if (frame.state === 'none') { section.hidden = true; return; }
376+
function apply(view) {
377+
if (!view.visible) { section.hidden = true; return; }
379378
section.hidden = false;
380-
if (frame.state === 'error') {
381-
label.textContent = 'Status unavailable';
382-
counts.textContent = '';
383-
detail.textContent = frame.detail || 'retry';
384-
return;
385-
}
386-
const c = frame.request_counts || {};
387-
const total = frame.request_count || 0;
388-
const done = (c.succeeded || 0) + (c.errored || 0) + (c.canceled || 0) + (c.expired || 0);
389-
const pct = total ? Math.round((done / total) * 100) : 0;
390-
fill.style.width = pct + '%';
391-
counts.textContent = total ? `${done}/${total}` : '';
392-
if (isTerminal(frame)) {
393-
label.textContent = `Completed — ${c.succeeded || 0} succeeded, ${c.errored || 0} errored`;
394-
} else {
395-
label.textContent = frame.processing_status || 'processing';
396-
}
397-
detail.textContent = frame.batch_id ? `batch ${frame.batch_id}` : '';
379+
if (view.pct !== null) fill.style.width = view.pct + '%';
380+
label.textContent = view.label;
381+
counts.textContent = view.counts;
382+
detail.textContent = view.detail;
398383
}
399384

385+
// Server sends one frame per poll and closes after a none/error/terminal
386+
// frame; we es.close() on those so the browser does not auto-reconnect into
387+
// a none → close → reconnect loop. New batches are picked up on next load.
400388
const es = new EventSource('/api/batch/status/stream');
401389
es.onmessage = (ev) => {
402390
let frame;
403391
try { frame = JSON.parse(ev.data); } catch { return; }
404-
render(frame);
405-
// Server's last frame for these states — close to suppress reconnect.
406-
if (frame.state !== 'pending' || isTerminal(frame)) es.close();
392+
apply(batchView(frame));
393+
if (shouldClose(frame)) es.close();
407394
};
408395
// Transient drops: let the browser auto-reconnect (no handler needed).
409-
})();
396+
}
410397
</script>
411398
{% endblock %}

0 commit comments

Comments
 (0)