@nanobpm/nano-workforce 0.136.0 → 0.138.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,25 +1,30 @@
1
- // Contract guard for the Delivery Graphs compose → preview STAGE App View (issues #441 + #460).
1
+ // Contract guard for the Delivery Graphs compose → PREVIEW / STAGE App View (issues #441 + #460 + #516).
2
2
  //
3
3
  // The rich compile preview (mermaid diagram + humanNodes[] stop-points + sideEffects[] + inline
4
- // path-qualified errors) is surfaced by an `appView` embed (pages/delivery-graphs/) over the EXISTING
5
- // previewDeliveryGraph door — a bare `actionForm` discards its response and so can render none of that.
6
- // Dispatch is deliberately NOT in this view (issue #460): it is an OPERATOR row-action on the Staged
7
- // proposals grid on the same page. This test pins the wiring so it can't silently regress: the
8
- // sidecars exist, mount.js hits the preview door with a base-relative default (the #279 App-View
9
- // resolution class a leading-slash path 404s through the console iframe), it renders each preview
10
- // facet, and it exposes NO dispatch/approval affordance (the self-approval hole #460 closes).
4
+ // path-qualified errors) is surfaced by an `appView` embed (pages/delivery-graphs/) over the preview /
5
+ // stage doors — a bare `actionForm` discards its response and so can render none of that. Preview and
6
+ // Stage are SEPARATE operator actions (#516): Preview compiles without persisting; Stage persists a
7
+ // proposal. Dispatch is deliberately NOT in this view (issue #460): it is an OPERATOR row-action on the
8
+ // Staged proposals grid on the same page. This test pins the wiring so it can't silently regress: the
9
+ // sidecars exist, mount.js hits the preview + stage doors with base-relative defaults (the #279
10
+ // App-View resolution class a leading-slash path 404s), it renders each preview facet, its compose
11
+ // panel is collapsible, and it exposes NO dispatch/approval affordance (the self-approval hole #460
12
+ // closes).
11
13
  import { test } from "node:test";
12
14
  import { assert } from "#test-assert";
13
15
  import { readFileSync } from "node:fs";
16
+ import { parseAndCompileText } from "../app/deliveryGraphTextIngress.ts";
17
+ import { EXAMPLE_GRAPH } from "../pages/delivery-graphs/mount.js";
14
18
 
15
19
  const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
16
20
  const DIR = `${ROOT}pages/delivery-graphs`;
17
21
  const MOUNT_JS = readFileSync(`${DIR}/mount.js`, "utf8");
18
22
  const EMBED_HTML = readFileSync(`${DIR}/embed.html`, "utf8");
19
23
  const STANDALONE_HTML = readFileSync(`${DIR}/standalone.html`, "utf8");
24
+ const CSS = readFileSync(`${DIR}/delivery-graphs.css`, "utf8");
20
25
  const PAGE_JSON = readFileSync(`${ROOT}pages/delivery-graphs.page.json`, "utf8");
21
26
 
22
- // Pull the string default out of `const <name> = config.<name> ?? "<default>";` (or a module const).
27
+ // Pull the string default out of `const <name> = config.<name> ?? <CONST>;` (a module const).
23
28
  function defaultUrl(name: string): string {
24
29
  const m = MOUNT_JS.match(new RegExp(`${name}\\s*=\\s*config\\.\\w+\\s*\\?\\?\\s*(\\w+);`));
25
30
  assert(m, `mount.js must default ${name} from config with a fallback constant`);
@@ -36,33 +41,39 @@ test("#441: the delivery-graphs App View mounts the same module standalone and e
36
41
  }
37
42
  });
38
43
 
39
- test("#441: mount.js wires the preview+stage door", () => {
44
+ test("#516: mount.js wires SEPARATE preview and stage doors (base-relative)", () => {
40
45
  const previewUrl = defaultUrl("previewUrl");
41
46
  assert(previewUrl.endsWith("actions/delivery-graph/preview"), `previewUrl default "${previewUrl}" must hit the previewDeliveryGraph door`);
47
+ assert(!previewUrl.startsWith("/"), `default previewUrl "${previewUrl}" must be base-relative (App-View #279 resolution class)`);
48
+ const stageUrl = defaultUrl("stageUrl");
49
+ assert(stageUrl.endsWith("actions/delivery-graph/stage"), `stageUrl default "${stageUrl}" must hit the stageDeliveryGraph door`);
50
+ assert(!stageUrl.startsWith("/"), `default stageUrl "${stageUrl}" must be base-relative (App-View #279 resolution class)`);
51
+ // Preview and Stage are distinct buttons wired to distinct actions.
52
+ assert(/id="dg-preview"/.test(MOUNT_JS) && /id="dg-stage"/.test(MOUNT_JS), "mount.js must render distinct Preview and Stage buttons");
53
+ assert(/submit\(previewUrl,\s*false\)/.test(MOUNT_JS), "the Preview button must submit to the preview door WITHOUT staging");
54
+ assert(/submit\(stageUrl,\s*true\)/.test(MOUNT_JS), "the Stage button must submit to the stage door");
42
55
  });
43
56
 
44
- test("DI preview: mount.js wires the proposal-bpmn door (base-relative) and hands the XML to the host explorer", () => {
45
- const url = defaultUrl("proposalBpmnUrl");
46
- assert(url.endsWith("actions/delivery-graph/proposal-bpmn"), `proposalBpmnUrl default "${url}" must hit the previewProposalBpmn door`);
47
- assert(!url.startsWith("/"), `default proposalBpmnUrl "${url}" must be base-relative (App-View #279 resolution class)`);
48
- // The staged banner exposes a Preview-DI affordance, and clicking it hands the compiled XML to the
49
- // host console over the nano-navigate bridge with the definitionPreview target (never a dispatch).
50
- assert(/data-preview-di=/.test(MOUNT_JS), "mount.js must render a Preview-DI affordance carrying the proposal digest");
51
- assert(/target:\s*"definitionPreview"/.test(MOUNT_JS), "mount.js must post nano-navigate to the definitionPreview target");
52
- assert(/params:\s*\{\s*xml:/.test(MOUNT_JS), "mount.js must carry the compiled BPMN xml in the bridge message");
57
+ test("#516: the compose panel is collapsible", () => {
58
+ // Native <details> disclosure: keyboard-accessible, and the textarea is only hidden (never destroyed)
59
+ // when collapsed, so its value survives.
60
+ assert(/<details[^>]*class="[^"]*\bcompose\b/.test(MOUNT_JS), "the compose panel must be a collapsible <details class=compose>");
61
+ assert(/<summary>/.test(MOUNT_JS), "the collapsible compose panel must have a <summary> disclosure header");
62
+ assert(/\.compose\[open\]/.test(CSS), "the CSS must style the open/closed disclosure state");
53
63
  });
54
64
 
55
- // The #279 App-View resolution class: a default endpoint must be BASE-RELATIVE (no leading slash) so
56
- // it resolves under the console app-view base, not the console origin root (which 404s the door).
57
- test("#441/#279: default previewUrl is base-relative (no leading slash)", () => {
58
- const def = defaultUrl("previewUrl");
59
- assert(!def.startsWith("/"), `default previewUrl "${def}" must not start with "/" — a leading-slash path resolves against the console iframe ORIGIN, not the app-view base, so the door 404s`);
65
+ test("#516 DI preview: the compose view bridges the previewed BPMN to the host explorer WITHOUT staging", () => {
66
+ // The pure preview door returns the laid-out `bpmn`, so DI preview needs no proposal-bpmn round-trip
67
+ // (and therefore no staging). The compose view stashes the previewed BPMN and hands it to the host
68
+ // console over the nano-navigate bridge with the definitionPreview target (never a dispatch).
69
+ assert(!/proposal-bpmn/.test(MOUNT_JS), "mount.js must NOT round-trip the proposal-bpmn door the preview door returns the BPMN directly (#516)");
70
+ assert(/lastBpmn/.test(MOUNT_JS), "mount.js must stash the previewed BPMN to bridge on demand");
71
+ assert(/data-preview-di/.test(MOUNT_JS), "mount.js must render a Preview-DI affordance");
72
+ assert(/target:\s*"definitionPreview"/.test(MOUNT_JS), "mount.js must post nano-navigate to the definitionPreview target");
73
+ assert(/params:\s*\{\s*xml:\s*lastBpmn\s*\}/.test(MOUNT_JS), "mount.js must carry the previewed BPMN xml in the bridge message");
60
74
  });
61
75
 
62
76
  test("#441: the preview render consumes every compile facet the door returns", () => {
63
- // The whole point of #441: the preview data (diagram / humanNodes / sideEffects / errors) is rich
64
- // but was consumed by nothing. Assert the renderer touches each facet at a CONCRETE call site (not a
65
- // bare word, which a comment/string could satisfy) so a renderer that stops reading a field fails.
66
77
  const facetUse: Record<string, RegExp> = {
67
78
  diagram: /esc\(result\.diagram\)/,
68
79
  humanNodes: /renderHumanNodes\(result\.humanNodes\)/,
@@ -74,10 +85,16 @@ test("#441: the preview render consumes every compile facet the door returns", (
74
85
  }
75
86
  });
76
87
 
88
+ test("#516: the built-in 'Load example' graph compiles clean (regression: it used to fail)", async () => {
89
+ // The example shipped a `soak` wait node missing its required `wait.kind`, so 'Load example' →
90
+ // Preview always 400'd. Drive the EXACT string the button injects through the SAME compiler the
91
+ // preview/stage doors use, and assert it is accepted — so a future edit to EXAMPLE_GRAPH can't
92
+ // silently re-break the one graph an operator reaches for first.
93
+ const result = await parseAndCompileText({ graphJson: EXAMPLE_GRAPH });
94
+ assert(result.ok, result.ok ? "" : `the built-in example must compile, got: ${JSON.stringify(result.body)}`);
95
+ });
96
+
77
97
  test("#460: the compose view exposes NO dispatch or approval affordance — it only previews + stages", () => {
78
- // Issue #460 removes the agent-reachable dispatch door. The compose view must not smuggle it back:
79
- // no dispatch door wiring, no approval two-step, no replayable approvalToken. Dispatch is the
80
- // operator's Staged-proposals row-action instead.
81
98
  assert(!/dispatchUrl/.test(MOUNT_JS), "mount.js must NOT wire a dispatch door (dispatch is an operator row-action, issue #460)");
82
99
  assert(!/delivery-graph\/dispatch/.test(MOUNT_JS), "mount.js must NOT post to the dispatch door");
83
100
  assert(!/awaiting-approval/.test(MOUNT_JS), "mount.js must NOT implement the removed awaiting-approval two-step");
@@ -85,11 +102,6 @@ test("#460: the compose view exposes NO dispatch or approval affordance — it o
85
102
  });
86
103
 
87
104
  test("#460/#511: dispatch is the operator's action on the Staged-proposals App-View", () => {
88
- // Dispatch is NOT in the compose view (asserted above). It lives on the Staged-proposals surface,
89
- // which is now an App-View (issue #511) rather than a declarative grid: a grid row-action can POST but
90
- // cannot hand the recompiled BPMN up to the host explorer, so a staged proposal had a Dispatch button
91
- // but no way to SEE the graph. The App-View carries BOTH Preview-DI and Dispatch. The wiring itself
92
- // (which doors staged.mount.js posts to) is pinned by delivery-graphs-staged-embed.test.ts.
93
105
  const page = JSON.parse(PAGE_JSON) as { nodes: Array<Record<string, any>> };
94
106
  const staged = page.nodes.find((n) => n.id === "delivery-graphs-staged");
95
107
  assert(staged, "the page must carry a Staged proposals surface");
@@ -1,76 +0,0 @@
1
- // Read-model derivation test for the epic delivery signal (issue #171). `deriveDelivery` is the
2
- // single source of truth the `plan_delivery` VIEW (061) encodes and the pollers derive at READ TIME
3
- // (epic #412 retired the stored `plans.delivery` / `plans.delivery_label` columns). It must cleanly
4
- // distinguish an epic whose fan-out is `done` but whose slices are still CONVERGING from one where
5
- // every slice PR has LANDED, and count abandoned/converged PRs as resolved-not-landed (never
6
- // `landed`). The delivery-aware `list_bucket`/`ack_open` bucket derivation now lives in the
7
- // `plan_read_model` VIEW (074), cross-checked against the pure helpers in app/plansReadModel.test.ts.
8
- import { test } from "node:test";
9
- import { assert, assertEquals } from "#test-assert";
10
- import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
11
-
12
- test("all slice PRs merged -> landed", () => {
13
- const r = deriveDelivery("done", ["merged", "merged", "merged"]);
14
- assertEquals(r.delivery, "landed");
15
- assertEquals(r.prsOpened, 3);
16
- assertEquals(r.prsMerged, 3);
17
- assertEquals(r.prsInFlight, 0);
18
- assertEquals(r.label, "3/3 slices merged");
19
- });
20
-
21
- test("one slice PR still in flight -> converging", () => {
22
- const r = deriveDelivery("done", ["merged", "converging", "merged"]);
23
- assertEquals(r.delivery, "converging");
24
- assertEquals(r.prsOpened, 3);
25
- assertEquals(r.prsMerged, 2);
26
- assertEquals(r.prsInFlight, 1);
27
- assertEquals(r.label, "2/3 slices merged, 1 converging");
28
- });
29
-
30
- test("mixed merged/abandoned (all terminal, not all merged) -> resolved-not-landed (null)", () => {
31
- const r = deriveDelivery("done", ["merged", "abandoned", "merged"]);
32
- assertEquals(r.delivery, null);
33
- assertEquals(r.label, null);
34
- assertEquals(r.prsOpened, 3);
35
- assertEquals(r.prsMerged, 2);
36
- // abandoned is terminal, so it is NOT counted as in flight.
37
- assertEquals(r.prsInFlight, 0);
38
- });
39
-
40
- test("a converged (review-only, unmerged) slice keeps the epic out of landed", () => {
41
- // `converged` is terminal but not `merged`: resolved-not-landed, like abandoned.
42
- const r = deriveDelivery("done", ["merged", "converged"]);
43
- assertEquals(r.delivery, null);
44
- assertEquals(r.prsInFlight, 0);
45
- assertEquals(r.prsMerged, 1);
46
- });
47
-
48
- test("plan not yet done -> no delivery signal even with slice PRs", () => {
49
- for (const status of ["planning", "dispatched"]) {
50
- const r = deriveDelivery(status, ["merged", "converging"]);
51
- assertEquals(r.delivery, null, `status=${status}`);
52
- assertEquals(r.label, null, `status=${status}`);
53
- }
54
- });
55
-
56
- test("done but zero slice PRs -> no delivery signal", () => {
57
- const r = deriveDelivery("done", []);
58
- assertEquals(r.delivery, null);
59
- assertEquals(r.prsOpened, 0);
60
- });
61
-
62
- test("a single in-flight slice on a done plan is converging, not landed", () => {
63
- const r = deriveDelivery("done", ["waiting_review"]);
64
- assertEquals(r.delivery, "converging");
65
- assertEquals(r.label, "0/1 slices merged, 1 converging");
66
- });
67
-
68
- test("every non-terminal status counts as in flight", () => {
69
- const inFlight = ["converging", "waiting_review", "escalated", "queued", "open", "opened"];
70
- for (const s of inFlight) {
71
- assert(!TERMINAL_STATUSES.includes(s), `${s} must not be terminal`);
72
- const r = deriveDelivery("done", [s]);
73
- assertEquals(r.delivery, "converging", `status ${s}`);
74
- assertEquals(r.prsInFlight, 1, `status ${s}`);
75
- }
76
- });
@@ -1,170 +0,0 @@
1
- // Coverage for the Epic-detail wave visualization + task→representation links (issue #411).
2
- //
3
- // Two guards, mirroring the repo's split between a derived-read-model test (migration042.test.ts —
4
- // apply the migration to a real in-memory DB and assert its output) and a page-projection guard
5
- // (waitGateVisibility.test.ts — pure text assertions that the declarative page wires the surface):
6
- //
7
- // 1. The VIEW rollup over sample `plan_tasks` × `pull_requests` rows: the six-way per-wave count
8
- // partition and the pre-formatted `bar` string. Because `plan_wave_summary` is a VIEW (the whole
9
- // point of #411 — a single derived source of truth, enabled by nano-ide#424) this exercises the
10
- // real SQLite view, not a re-implementation.
11
- // 2. The epic-detail page projects the wave banner, the per-wave summary section, and the
12
- // task→representation links (PR url + processExplorer instance) on the wave-state grid.
13
- import { readFileSync } from "node:fs";
14
- import { DatabaseSync } from "node:sqlite";
15
- import { test } from "node:test";
16
- import { fileURLToPath } from "node:url";
17
- import { assert, assertEquals } from "#test-assert";
18
-
19
- const MIGRATION = fileURLToPath(new URL("../db/migrations/059_plan_wave_summary.sql", import.meta.url));
20
- const PAGE = fileURLToPath(new URL("../pages/epic-detail.page.json", import.meta.url));
21
-
22
- /** A DB with the base `plan_tasks` / `pull_requests` shapes the views read, plus the views applied. */
23
- function viewDb(): DatabaseSync {
24
- const db = new DatabaseSync(":memory:");
25
- db.exec(
26
- `CREATE TABLE plan_tasks (
27
- id INTEGER PRIMARY KEY, plan_key TEXT, task_index INTEGER, task_id TEXT, title TEXT,
28
- prompt TEXT, status TEXT, pr_key TEXT, summary TEXT, created_at TEXT, updated_at TEXT,
29
- wave INTEGER, open_question TEXT, answer TEXT, draft_pr_key TEXT, corr_key TEXT);
30
- CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, url TEXT, status TEXT, process_key TEXT);`,
31
- );
32
- db.exec(readFileSync(MIGRATION, "utf8"));
33
- return db;
34
- }
35
-
36
- function addTask(
37
- db: DatabaseSync,
38
- plan_key: string,
39
- task_index: number,
40
- status: string,
41
- wave: number,
42
- pr?: { pr_key: string; url: string; status: string; process_key: string },
43
- ): void {
44
- db.prepare(
45
- "INSERT INTO plan_tasks (plan_key, task_index, task_id, status, pr_key, wave) VALUES (?, ?, ?, ?, ?, ?)",
46
- ).run(plan_key, task_index, `t${task_index}`, status, pr?.pr_key ?? null, wave);
47
- if (pr) {
48
- db.prepare(
49
- "INSERT INTO pull_requests (pr_key, url, status, process_key) VALUES (?, ?, ?, ?)",
50
- ).run(pr.pr_key, pr.url, pr.status, pr.process_key);
51
- }
52
- }
53
-
54
- test("plan_wave_summary partitions each wave's tasks and pre-formats the progress bar", () => {
55
- const db = viewDb();
56
- const plan = "o/r#1";
57
- // Wave 0 — 5 tasks: 3 merged, 1 converging (in-flight), 1 blocked (no PR).
58
- addTask(db, plan, 0, "opened", 0, { pr_key: "o/r#10", url: "https://gh/10", status: "merged", process_key: "P10" });
59
- addTask(db, plan, 1, "opened", 0, { pr_key: "o/r#11", url: "https://gh/11", status: "merged", process_key: "P11" });
60
- addTask(db, plan, 2, "opened", 0, { pr_key: "o/r#12", url: "https://gh/12", status: "merged", process_key: "P12" });
61
- addTask(db, plan, 3, "opened", 0, { pr_key: "o/r#13", url: "https://gh/13", status: "converging", process_key: "P13" });
62
- addTask(db, plan, 4, "blocked", 0);
63
- // Wave 1 — an escalated slice (with a draft PR) and a skipped slice.
64
- addTask(db, plan, 5, "escalated", 1, { pr_key: "o/r#14", url: "https://gh/14", status: "escalated", process_key: "P14" });
65
- addTask(db, plan, 6, "skipped", 1);
66
-
67
- const rows = db
68
- .prepare("SELECT * FROM plan_wave_summary WHERE plan_key = ? ORDER BY wave")
69
- .all(plan) as Array<Record<string, unknown>>;
70
- assertEquals(rows.length, 2);
71
-
72
- const w0 = rows[0];
73
- assertEquals(w0.total, 5);
74
- assertEquals(w0.merged, 3);
75
- assertEquals(w0.in_flight, 1);
76
- assertEquals(w0.blocked, 1);
77
- assertEquals(w0.escalated, 0);
78
- assertEquals(w0.skipped, 0);
79
- // 3 filled + 2 empty glyphs (width = total), then the named non-zero categories.
80
- assertEquals(w0.bar, "▓▓▓░░ 3/5 merged · 1 in-flight · 1 blocked");
81
-
82
- const w1 = rows[1];
83
- assertEquals(w1.total, 2);
84
- assertEquals(w1.merged, 0);
85
- assertEquals(w1.in_flight, 0);
86
- assertEquals(w1.escalated, 1);
87
- assertEquals(w1.skipped, 1);
88
- assertEquals(w1.bar, "░░ 0/2 merged · 1 escalated · 1 skipped");
89
- });
90
-
91
- test("a merged PR wins over the task's own status, and unlevelized tasks are excluded", () => {
92
- const db = viewDb();
93
- const plan = "o/r#2";
94
- // An escalated task whose PR nonetheless merged counts as merged, not escalated (PR wins).
95
- addTask(db, plan, 0, "escalated", 0, { pr_key: "o/r#20", url: "https://gh/20", status: "merged", process_key: "P20" });
96
- // A task with no wave yet (not levelized) must not appear in any wave row.
97
- db.prepare(
98
- "INSERT INTO plan_tasks (plan_key, task_index, task_id, status, wave) VALUES (?, ?, ?, ?, NULL)",
99
- ).run(plan, 1, "t1", "pending");
100
-
101
- const rows = db
102
- .prepare("SELECT wave, total, merged, escalated FROM plan_wave_summary WHERE plan_key = ?")
103
- .all(plan) as Array<Record<string, unknown>>;
104
- assertEquals(rows.length, 1);
105
- assertEquals(rows[0].wave, 0);
106
- assertEquals(rows[0].total, 1);
107
- assertEquals(rows[0].merged, 1);
108
- assertEquals(rows[0].escalated, 0);
109
- });
110
-
111
- test("plan_wave_tasks carries each task's PR url + process_key link targets", () => {
112
- const db = viewDb();
113
- addTask(db, "o/r#3", 0, "opened", 0, { pr_key: "o/r#30", url: "https://gh/30", status: "converging", process_key: "P30" });
114
- addTask(db, "o/r#3", 1, "blocked", 0); // no PR → null link targets
115
-
116
- const rows = db
117
- .prepare("SELECT task_id, pr_key, pr_url, process_key FROM plan_wave_tasks WHERE plan_key = ? ORDER BY task_index")
118
- .all("o/r#3") as Array<Record<string, unknown>>;
119
- assertEquals(rows[0].pr_url, "https://gh/30");
120
- assertEquals(rows[0].process_key, "P30");
121
- assertEquals(rows[1].pr_url, null);
122
- assertEquals(rows[1].process_key, null);
123
- });
124
-
125
- test("epic-detail projects the wave banner, the per-wave summary, and task→representation links", () => {
126
- const page = JSON.parse(readFileSync(PAGE, "utf8"));
127
- const byId = (id: string) => page.nodes.find((n: { id: string }) => n.id === id);
128
-
129
- // 1. The epic-level wave banner: a prose node reading wave_label + epic_phase off the derived
130
- // `plan_read_model` VIEW (epic #412 — retiring the worker-maintained plans.wave_label column;
131
- // the banner now reads the single-source-of-truth view instead of the raw `plans` table).
132
- const banner = byId("wave-banner");
133
- assert(banner, "epic detail must show the epic-level wave banner");
134
- assertEquals(banner.props.data.table, "plan_read_model");
135
- assert(
136
- banner.props.data.filter.some((f: { field: string; eqParam?: boolean }) => f.field === "plan_key" && f.eqParam),
137
- "the banner is scoped to this epic",
138
- );
139
- assert(/\{\{\s*wave_label\s*\}\}/.test(banner.props.header), "the banner surfaces the wave_label");
140
- assert(/\{\{\s*epic_phase\s*\}\}/.test(banner.props.header), "the banner surfaces the epic phase");
141
-
142
- // 2. The per-wave summary section: a grid over the derived VIEW, ordered by wave, with the bar.
143
- const summary = byId("wave-summary");
144
- assert(summary, "epic detail must show the per-wave progress summary");
145
- assertEquals(summary.props.data.table, "plan_wave_summary");
146
- assertEquals(summary.props.data.orderBy.field, "wave");
147
- const summaryCols: string[] = summary.props.columns.map((c: { field: string }) => c.field);
148
- for (const f of ["wave", "bar", "merged", "in_flight", "blocked", "escalated", "skipped", "total"]) {
149
- assert(summaryCols.includes(f), `the summary grid shows ${f}`);
150
- }
151
-
152
- // 3. The wave-state grid links each in-flight task to its representation (PR + process instance).
153
- const waveState = byId("wave-state");
154
- assert(waveState, "epic detail must keep the wave-state grid");
155
- assertEquals(waveState.props.data.table, "plan_wave_tasks");
156
- const cols: Array<Record<string, unknown>> = waveState.props.columns;
157
- const prCol = cols.find((c) => c.field === "pr_key");
158
- assertEquals(prCol?.linkField, "pr_url", "the PR cell links to the GitHub PR url");
159
- const statusCol = cols.find((c) => c.field === "status") as {
160
- link?: { kind?: string; keyField?: string };
161
- };
162
- assertEquals(statusCol.link?.kind, "processExplorer", "the status cell links to the process instance");
163
- assertEquals(statusCol.link?.keyField, "process_key");
164
- // The existing tabs (Active / Skipped / All) and detail drawer must still be present.
165
- assertEquals(waveState.props.tabs.length, 3);
166
- const detailFields: string[] = waveState.props.detail.fields.map((f: { field: string }) => f.field);
167
- for (const f of ["open_question", "answer", "draft_pr_key", "prompt"]) {
168
- assert(detailFields.includes(f), `the detail drawer keeps ${f}`);
169
- }
170
- });