@nanobpm/urban 0.61.0 → 0.63.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.
@@ -14,6 +14,7 @@ export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
14
14
  export { createLogger } from "./core/logger.ts";
15
15
  export type { Logger, LogLevel, LogFields, LogSink } from "./core/logger.ts";
16
16
  export { BpmnError, isBpmnError } from "./core/host.ts";
17
+ export { ENGINE_CLIENT_METHODS } from "./core/host.ts";
17
18
  export { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors, } from "./core/modules/connectors.ts";
18
19
  export type { ConnectorsHandle } from "./core/modules/connectors.ts";
19
20
  export { DataLayer, TypeRepo } from "./core/modules/datasource.ts";
@@ -18,6 +18,10 @@ export { createLogger } from "./core/logger.js";
18
18
  // BPMN error contract: a handler throws `BpmnError(code)` to raise a modelled
19
19
  // error routed to an error boundary (ADR 0050), instead of a retryable failure.
20
20
  export { BpmnError, isBpmnError } from "./core/host.js";
21
+ // The runtime source of truth for the `EngineClient` method surface (issue #341): a test
22
+ // double / adapter iterates it to prove at runtime that it implements the full interface,
23
+ // catching the "fake lags the SDK" seam-lag class categorically.
24
+ export { ENGINE_CLIENT_METHODS } from "./core/host.js";
21
25
  // Connector packs (ADR 0050, in-process port)
22
26
  export { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors, } from "./core/modules/connectors.js";
23
27
  // Data layer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.61.0",
3
+ "version": "0.63.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1514,6 +1514,80 @@ function renderProse(node) {
1514
1514
  return card;
1515
1515
  }
1516
1516
 
1517
+ // Build a detail/escalation answer form for a single grid row. This is the ONE
1518
+ // implementation of the `detail.form` contract — shared verbatim by the
1519
+ // top-level grid's detail panel AND a dataGrid child-grid row (#333) so the two
1520
+ // can never drift. The form has a single free-text field (f.inputKey), an
1521
+ // optional prompt echoed from the row (f.promptField), and a route-driven submit
1522
+ // (f.action) whose path/body interpolate {{form.*}} / {{row.*}} tokens. It
1523
+ // renders only when the row's f.showWhenField is truthy (an answerable
1524
+ // escalation), returning null otherwise. On a successful submit it invokes
1525
+ // onSuccess — the top-level grid re-polls the whole page (pc:refresh) so the
1526
+ // answered row drops out on the next tick; a child grid re-fetches just its own
1527
+ // rows, matching the Tasks page where the row disappears once its user-task
1528
+ // completes.
1529
+ // A disclosure chevron for expanding an inline detail row. This is the ONE place
1530
+ // a "▸/▾" toggle button is built, so the glyph and its `aria-expanded` state can
1531
+ // never drift apart (they used to be set independently, leaving assistive tech
1532
+ // unaware a row had opened). Every chevron gets an explicit type="button" (so it
1533
+ // never submits an enclosing form) and an accessible name.
1534
+ /** @param {string} [label] @returns {HTMLElement} */
1535
+ function chevronToggle(label) {
1536
+ return el("button", {
1537
+ class: "pc-btn pc-btn-sm pc-chevron",
1538
+ type: "button",
1539
+ "aria-label": label || "Toggle details",
1540
+ "aria-expanded": "false",
1541
+ }, "▸");
1542
+ }
1543
+ // Set a chevron's open/closed state, keeping the glyph and aria-expanded in lock-step.
1544
+ /** @param {HTMLElement} btn @param {boolean} open */
1545
+ function setChevronOpen(btn, open) {
1546
+ btn.textContent = open ? "▾" : "▸";
1547
+ btn.setAttribute("aria-expanded", String(open));
1548
+ }
1549
+
1550
+ /** @param {any} f
1551
+ * @param {Record<string, any>} row
1552
+ * @param {() => void | Promise<void>} onSuccess
1553
+ * @returns {HTMLElement|null}
1554
+ */
1555
+ function buildDetailForm(f, row, onSuccess) {
1556
+ if (!f || !row[f.showWhenField]) return null;
1557
+ const box = el("div", { class: "pc-subform" });
1558
+ if (f.title) box.append(el("div", { class: "pc-subform-title" }, f.title));
1559
+ if (f.promptField && row[f.promptField] != null) {
1560
+ box.append(el("div", { class: "pc-prompt" }, String(row[f.promptField])));
1561
+ }
1562
+ const input = el("textarea", { class: "pc-textarea", placeholder: f.inputLabel || f.inputKey, "aria-label": f.inputLabel || f.inputKey });
1563
+ // role=status + aria-live so the "Sending…"/success/error transitions are
1564
+ // announced to screen readers instead of silently changing text.
1565
+ const msg = el("p", { class: "pc-msg", role: "status", "aria-live": "polite" });
1566
+ const btn = el("button", { class: "pc-btn pc-btn-sm", type: "button" }, f.submitLabel || "Submit");
1567
+ btn.addEventListener("click", async () => {
1568
+ btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Sending…";
1569
+ try {
1570
+ // The textarea value is the form's single field (f.inputKey); route + body
1571
+ // template come from f.action, e.g. { path: "/app/actions/message",
1572
+ // body: { name, correlationKey: "{{row.pr_key}}", variables: "{{form}}" } }.
1573
+ // Null-proto so an f.inputKey of "__proto__"/"constructor" can't mutate a prototype.
1574
+ const form = Object.create(null); form[f.inputKey] = input.value;
1575
+ await runRoute(f.action, { form, row });
1576
+ msg.className = "pc-msg ok"; msg.textContent = (f.action && f.action.successLabel) || "Sent";
1577
+ // Await onSuccess so a refresh that rejects (e.g. a child grid's re-fetch
1578
+ // failing) is surfaced here and re-enables the button below, instead of the
1579
+ // operator being stuck on a permanently-disabled button with a silent
1580
+ // failure. On success the refresh detaches/rebuilds this form, so leaving
1581
+ // the button disabled is moot; we only re-enable on the error path.
1582
+ await onSuccess();
1583
+ } catch (e) {
1584
+ btn.disabled = false; msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
1585
+ }
1586
+ });
1587
+ box.append(el("div", { class: "pc-field" }, input), btn, msg);
1588
+ return box;
1589
+ }
1590
+
1517
1591
  /** @param {any} node */
1518
1592
  function renderDataGrid(node) {
1519
1593
  const p = node.props;
@@ -1630,42 +1704,6 @@ function renderDataGrid(node) {
1630
1704
  return b;
1631
1705
  }
1632
1706
 
1633
- /** @param {Record<string, any>} row
1634
- * @param {any} formCfg the panel's own form config (top-level or child grid); a
1635
- * missing/undefined form means this panel has no form — it must NOT fall back to
1636
- * the top-level grid's form, or a child panel would render the parent's form.
1637
- * @returns {HTMLElement|null}
1638
- */
1639
- function detailForm(row, formCfg) {
1640
- const f = formCfg;
1641
- if (!f || !row[f.showWhenField]) return null;
1642
- const box = el("div", { class: "pc-subform" });
1643
- if (f.title) box.append(el("div", { class: "pc-subform-title" }, f.title));
1644
- if (f.promptField && row[f.promptField] != null) {
1645
- box.append(el("div", { class: "pc-prompt" }, String(row[f.promptField])));
1646
- }
1647
- const input = el("textarea", { class: "pc-textarea", placeholder: f.inputLabel || f.inputKey });
1648
- const msg = el("p", { class: "pc-msg" });
1649
- const btn = el("button", { class: "pc-btn pc-btn-sm" }, f.submitLabel || "Submit");
1650
- btn.addEventListener("click", async () => {
1651
- btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Sending…";
1652
- try {
1653
- // The textarea value is the form's single field (f.inputKey); route + body
1654
- // template come from f.action, e.g. { path: "/app/actions/message",
1655
- // body: { name, correlationKey: "{{row.pr_key}}", variables: "{{form}}" } }.
1656
- // Null-proto so an f.inputKey of "__proto__"/"constructor" can't mutate a prototype.
1657
- const form = Object.create(null); form[f.inputKey] = input.value;
1658
- await runRoute(f.action, { form, row });
1659
- msg.className = "pc-msg ok"; msg.textContent = (f.action && f.action.successLabel) || "Sent";
1660
- document.dispatchEvent(new CustomEvent("pc:refresh"));
1661
- } catch (e) {
1662
- btn.disabled = false; msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
1663
- }
1664
- });
1665
- box.append(el("div", { class: "pc-field" }, input), btn, msg);
1666
- return box;
1667
- }
1668
-
1669
1707
  /** @param {any} cg
1670
1708
  * @param {Record<string, any>} row
1671
1709
  */
@@ -1685,7 +1723,11 @@ function renderDataGrid(node) {
1685
1723
  // parent rowKey + child rowKey, so it survives the top-level grid's refresh
1686
1724
  // poll (which rebuilds this whole subtree) and a full reload, mirroring how
1687
1725
  // groupBy group-collapse is persisted. Absent a `detail` block the child grid
1688
- // is byte-for-byte unchanged (backward compatible).
1726
+ // is byte-for-byte unchanged (backward compatible). A `detail.form` (#333) is
1727
+ // rendered inside that panel by the shared detailPanel/buildDetailForm, and its
1728
+ // submit re-fetches only this child grid (see load() below) rather than the
1729
+ // whole page — so answering a nested escalation drops the answered row without
1730
+ // disturbing the parent grid.
1689
1731
  const cdetail = cg.detail || cg.expandable || null;
1690
1732
  const chasExpand = cdetail != null;
1691
1733
  const cextra = (cg.lazyField ? 1 : 0) + (chasExpand ? 1 : 0);
@@ -1727,12 +1769,20 @@ function renderDataGrid(node) {
1727
1769
  // the catch's error row have a valid, table-spanning colspan — if getJSON()
1728
1770
  // throws, a lazily-assigned cspan would still be undefined here.
1729
1771
  const cspan = String((ccols.length || 1) + cextra);
1730
- try {
1772
+ // Re-fetch and repaint just this child grid's rows. Wired to the child detail
1773
+ // panel's form onSuccess so answering a nested escalation drops the row once
1774
+ // its user-task completes — the child-scoped mirror of the top-level grid's
1775
+ // pc:refresh re-poll, without disturbing the parent grid or its other detail
1776
+ // rows (which a global pc:refresh would leave untouched anyway, since open
1777
+ // detail rows are reused across the poll).
1778
+ async function load() {
1731
1779
  /** @type {{ rows: Array<Record<string, any>> }} */
1732
1780
  const { rows } = await getJSON(dataUrl(cg.source || "app", cg.table,
1733
1781
  [{ field: cg.childField, eq: row[cg.parentField] }], cg.orderBy));
1782
+ cbody.replaceChildren();
1734
1783
  if (!rows.length) {
1735
1784
  cbody.append(el("tr", {}, el("td", { colspan: cspan }, "None")));
1785
+ return;
1736
1786
  }
1737
1787
  for (const cr of rows) {
1738
1788
  const cells = ccols.map((c, i) => gridCell(c, cr, croles[i]));
@@ -1755,26 +1805,33 @@ function renderDataGrid(node) {
1755
1805
  // Per-row expander cell (trailing, after any lazyField cell): a chevron
1756
1806
  // that reveals this row's detailPanel in a following full-width row. The
1757
1807
  // panel is built lazily on first open (and eagerly when the row starts
1758
- // expanded), matching the top-level grid.
1808
+ // expanded), matching the top-level grid. The panel's escalation form (if
1809
+ // any) is wired to load() so answering it re-fetches only this child grid.
1810
+ // The chevron uses the shared chevronToggle()/setChevronOpen() so its glyph
1811
+ // and aria-expanded stay in lock-step for assistive tech.
1759
1812
  /** @type {any} */
1760
1813
  let cdtr = null;
1761
1814
  if (chasExpand) {
1762
1815
  const crk = crowKeyOf(cr);
1763
1816
  const cskey = ckeyBase && crk != null ? ckeyBase + crk : null;
1764
1817
  const collapsed = cskey ? readCollapsed(cskey, cdefaultCollapsed) : cdefaultCollapsed;
1765
- const ctoggle = el("button", { class: "pc-btn pc-btn-sm pc-chevron" }, collapsed ? "▸" : "▾");
1818
+ const ctoggle = chevronToggle("Toggle row details");
1819
+ setChevronOpen(ctoggle, !collapsed);
1766
1820
  cells.push(el("td", { class: "pc-row-actions" }, ctoggle));
1767
1821
  cdtr = el("tr", { hidden: collapsed ? "" : null }, el("td", { colspan: cspan }));
1768
1822
  let built = false;
1769
1823
  const build = () => {
1770
- if (!built) { built = true; cdtr.firstChild.append(detailPanel(cr, cdetail)); }
1824
+ // Return the reload promise (don't swallow it) so buildDetailForm can
1825
+ // surface a failed re-fetch and re-enable its submit button instead of
1826
+ // leaving the operator stuck on a false "Sent".
1827
+ if (!built) { built = true; cdtr.firstChild.append(detailPanel(cr, cdetail, () => load())); }
1771
1828
  };
1772
1829
  if (!collapsed) build();
1773
1830
  ctoggle.addEventListener("click", /** @param {MouseEvent} ev */ (ev) => {
1774
1831
  ev.stopPropagation();
1775
1832
  const open = cdtr.hidden;
1776
1833
  cdtr.hidden = !open;
1777
- ctoggle.textContent = open ? "▾" : "▸";
1834
+ setChevronOpen(ctoggle, open);
1778
1835
  if (open) build();
1779
1836
  if (cskey) writeCollapsed(cskey, !open);
1780
1837
  });
@@ -1788,8 +1845,11 @@ function renderDataGrid(node) {
1788
1845
  cbody.append(ctr);
1789
1846
  if (cdtr) cbody.append(cdtr);
1790
1847
  }
1848
+ }
1849
+ try {
1850
+ await load();
1791
1851
  } catch (e) {
1792
- cbody.append(el("tr", {}, el("td", { colspan: cspan }, String(e.message || e))));
1852
+ cbody.replaceChildren(el("tr", {}, el("td", { colspan: cspan }, String(e.message || e))));
1793
1853
  }
1794
1854
  return wrap;
1795
1855
  }
@@ -1800,8 +1860,12 @@ function renderDataGrid(node) {
1800
1860
  * and an optional escalation form.
1801
1861
  * @param {Record<string, any>} row
1802
1862
  * @param {any} [cfg] detail config; defaults to the top-level grid's `detail`
1863
+ * @param {(() => void)} [onSuccess] invoked after the panel's escalation form
1864
+ * submits successfully. Defaults to a whole-page `pc:refresh` re-poll (the
1865
+ * top-level grid's behaviour); a child grid passes a child-scoped reload so
1866
+ * answering a nested escalation refreshes only that child grid, not the page.
1803
1867
  */
1804
- function detailPanel(row, cfg) {
1868
+ function detailPanel(row, cfg, onSuccess) {
1805
1869
  const d = cfg || detail;
1806
1870
  const box = el("div", { class: "pc-detail" });
1807
1871
  if (d.linkField && row[d.linkField]) {
@@ -1825,7 +1889,7 @@ function renderDataGrid(node) {
1825
1889
  box.append(holder);
1826
1890
  childGrid(cg, row).then((w) => holder.replaceChildren(w));
1827
1891
  }
1828
- const form = detailForm(row, d.form);
1892
+ const form = buildDetailForm(d.form, row, onSuccess || (() => { document.dispatchEvent(new CustomEvent("pc:refresh")); }));
1829
1893
  if (form) box.append(form);
1830
1894
  return box;
1831
1895
  }
@@ -1840,7 +1904,7 @@ function renderDataGrid(node) {
1840
1904
  if (hasExtra) {
1841
1905
  const actionCell = el("td", { class: "pc-row-actions" });
1842
1906
  if (detail) {
1843
- toggle = el("button", { class: "pc-btn pc-btn-sm pc-chevron" }, "▸");
1907
+ toggle = chevronToggle("Toggle row details");
1844
1908
  actionCell.append(toggle);
1845
1909
  }
1846
1910
  for (const ra of rowActions) {
@@ -1872,12 +1936,12 @@ function renderDataGrid(node) {
1872
1936
  if (isOpen) { entry.built = true; dtr.firstChild.append(detailPanel(row)); }
1873
1937
  }
1874
1938
  const dtr = entry.dtr;
1875
- toggle.textContent = dtr.hidden ? "▸" : "▾";
1939
+ setChevronOpen(toggle, !dtr.hidden);
1876
1940
  toggle.addEventListener("click", /** @param {MouseEvent} ev */ (ev) => {
1877
1941
  ev.stopPropagation();
1878
1942
  const open = dtr.hidden;
1879
1943
  dtr.hidden = !open;
1880
- toggle.textContent = open ? "▾" : "▸";
1944
+ setChevronOpen(toggle, open);
1881
1945
  if (open) {
1882
1946
  if (key != null) expanded.add(key);
1883
1947
  if (!entry.built) { entry.built = true; dtr.firstChild.append(detailPanel(row)); }
@@ -2371,4 +2435,4 @@ function boot() {
2371
2435
  // renderer functions / RENDERERS registry can be imported directly.
2372
2436
  if (typeof document !== "undefined" && document.getElementById("page")) boot();
2373
2437
 
2374
- export { RENDERERS, renderText, renderButton, renderProse, renderNav, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell };
2438
+ export { RENDERERS, renderText, renderButton, renderProse, renderNav, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, chevronToggle, setChevronOpen };
@@ -0,0 +1,268 @@
1
+ // Unit tests for buildDetailForm (#333) — the single shared implementation of the
2
+ // `detail.form` answer/resume contract used by BOTH the top-level dataGrid detail
3
+ // panel and a child-grid per-row expansion. Because #333 lets a child-grid row
4
+ // host the same form, the contract must be exercised directly (render gating,
5
+ // prompt echo, and the interpolated route-driven submit) so the top-level ↔
6
+ // child-grid parity can't silently drift.
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { buildDetailForm, chevronToggle, setChevronOpen } from "./runtime.browser.js";
10
+
11
+ // ── Fake DOM with fire-able listeners ───────────────────────────────────────
12
+ // Richer than the render-only fake in runtime.browser.test.ts: it records event
13
+ // listeners (so a test can fire the submit click), exposes a settable textarea
14
+ // `value`, a `firstChild`, and `replaceChildren` — the surface buildDetailForm's
15
+ // interactive path touches. Every created node is collected on `created` so a
16
+ // test can grab the textarea/button without a real querySelector.
17
+ class FakeElement {
18
+ tagName: string;
19
+ attributes: Record<string, string> = {};
20
+ className = "";
21
+ children: Array<FakeElement | { text: string }> = [];
22
+ textContent = "";
23
+ dataset: Record<string, string> = {};
24
+ hidden = false;
25
+ disabled = false;
26
+ value = "";
27
+ style = { setProperty() {} };
28
+ listeners: Record<string, Array<(ev: unknown) => unknown>> = {};
29
+
30
+ constructor(tag: string) {
31
+ this.tagName = String(tag).toUpperCase();
32
+ }
33
+ setAttribute(k: string, v: unknown): void {
34
+ this.attributes[k] = String(v);
35
+ if (k === "class") this.className = String(v);
36
+ }
37
+ getAttribute(k: string): string | null {
38
+ return k in this.attributes ? this.attributes[k] : null;
39
+ }
40
+ addEventListener(type: string, fn: (ev: unknown) => unknown): void {
41
+ (this.listeners[type] ||= []).push(fn);
42
+ }
43
+ async fire(type: string): Promise<void> {
44
+ for (const fn of this.listeners[type] || []) await fn({ stopPropagation() {} });
45
+ }
46
+ append(...kids: Array<FakeElement | string>): void {
47
+ for (const kid of kids) {
48
+ if (typeof kid === "string") {
49
+ this.children.push({ text: kid });
50
+ this.textContent += kid;
51
+ } else {
52
+ this.children.push(kid);
53
+ this.textContent += kid.textContent || "";
54
+ }
55
+ }
56
+ }
57
+ replaceChildren(...kids: Array<FakeElement | string>): void {
58
+ this.children = [];
59
+ this.textContent = "";
60
+ this.append(...kids);
61
+ }
62
+ get firstChild(): FakeElement | { text: string } | null {
63
+ return this.children[0] ?? null;
64
+ }
65
+ querySelector(): null {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ function installFakeDom(created: FakeElement[]): () => void {
71
+ const doc = {
72
+ createElement: (tag: string) => {
73
+ const n = new FakeElement(tag);
74
+ created.push(n);
75
+ return n;
76
+ },
77
+ getElementById: () => null,
78
+ dispatchEvent: () => true,
79
+ };
80
+ const prior = Reflect.getOwnPropertyDescriptor(globalThis, "document");
81
+ Reflect.set(globalThis, "document", doc);
82
+ return () => {
83
+ if (prior) Reflect.defineProperty(globalThis, "document", prior);
84
+ else Reflect.deleteProperty(globalThis, "document");
85
+ };
86
+ }
87
+
88
+ const ACTION = {
89
+ path: "/app/api/actions/complete-user-task",
90
+ body: { taskId: "{{row.task_id}}", variables: "{{form}}" },
91
+ successLabel: "Answered ✓",
92
+ };
93
+ const FORM = {
94
+ showWhenField: "answerable",
95
+ promptField: "question",
96
+ inputKey: "comment",
97
+ inputLabel: "Your answer",
98
+ submitLabel: "Answer & resume",
99
+ action: ACTION,
100
+ };
101
+
102
+ test("#333: buildDetailForm renders nothing when the row's showWhenField is falsy or missing", (t) => {
103
+ const created: FakeElement[] = [];
104
+ t.after(installFakeDom(created));
105
+ assert.equal(buildDetailForm(FORM, { answerable: false, question: "Why?" }, () => {}), null);
106
+ assert.equal(buildDetailForm(FORM, { question: "Why?" }, () => {}), null);
107
+ assert.equal(buildDetailForm(null, { answerable: true }, () => {}), null);
108
+ });
109
+
110
+ test("#333: buildDetailForm echoes the prompt and labels the field/submit from the form config", (t) => {
111
+ const created: FakeElement[] = [];
112
+ t.after(installFakeDom(created));
113
+ const box = buildDetailForm(FORM, { answerable: true, question: "Ship it?" }, () => {});
114
+ assert.ok(box, "an answerable row must render a form");
115
+ assert.match(box!.textContent, /Ship it\?/, "the promptField value is echoed");
116
+ const textarea = created.find((n) => n.tagName === "TEXTAREA");
117
+ const button = created.find((n) => n.tagName === "BUTTON");
118
+ assert.equal(textarea?.getAttribute("placeholder"), "Your answer");
119
+ assert.equal(button?.textContent, "Answer & resume");
120
+ });
121
+
122
+ test("#333: the answer form is screen-reader accessible — textarea has an aria-label and the status is a live region", (t) => {
123
+ const created: FakeElement[] = [];
124
+ t.after(installFakeDom(created));
125
+ const box = buildDetailForm(FORM, { answerable: true, question: "Ship it?" }, () => {});
126
+ assert.ok(box);
127
+ const textarea = created.find((n) => n.tagName === "TEXTAREA");
128
+ // The placeholder alone is not an accessible name; an explicit aria-label makes
129
+ // the field usable without a visible <label>.
130
+ assert.equal(textarea?.getAttribute("aria-label"), "Your answer");
131
+ const msg = created.find((n) => n.tagName === "P" && n.className.includes("pc-msg"));
132
+ assert.equal(msg?.getAttribute("role"), "status", "status message is a live region role");
133
+ assert.equal(msg?.getAttribute("aria-live"), "polite", "status transitions are announced politely");
134
+ // Explicit type="button" so the submit control can never trigger an unintended
135
+ // submit/navigation if the form is ever nested inside a real <form>.
136
+ const button = created.find((n) => n.tagName === "BUTTON");
137
+ assert.equal(button?.getAttribute("type"), "button", "submit control is an explicit type=button");
138
+ });
139
+
140
+ test("#333: the answer form's aria-label falls back to inputKey when inputLabel is absent", (t) => {
141
+ const created: FakeElement[] = [];
142
+ t.after(installFakeDom(created));
143
+ const noLabel = { ...FORM, inputLabel: undefined };
144
+ buildDetailForm(noLabel, { answerable: true }, () => {});
145
+ const textarea = created.find((n) => n.tagName === "TEXTAREA");
146
+ assert.equal(textarea?.getAttribute("aria-label"), "comment", "aria-label mirrors the placeholder fallback (inputKey)");
147
+ });
148
+
149
+ test("#333: chevronToggle is an accessible disclosure button and setChevronOpen keeps the glyph and aria-expanded in lock-step", (t) => {
150
+ const created: FakeElement[] = [];
151
+ t.after(installFakeDom(created));
152
+ const btn = chevronToggle("Toggle answer form");
153
+ assert.equal(btn.getAttribute("type"), "button", "explicit type so it never submits an enclosing form");
154
+ assert.equal(btn.getAttribute("aria-label"), "Toggle answer form", "has an accessible name");
155
+ assert.equal(btn.getAttribute("aria-expanded"), "false", "starts collapsed");
156
+ assert.equal(btn.textContent, "▸");
157
+
158
+ setChevronOpen(btn, true);
159
+ assert.equal(btn.textContent, "▾", "glyph flips to open");
160
+ assert.equal(btn.getAttribute("aria-expanded"), "true", "aria-expanded tracks the glyph");
161
+
162
+ setChevronOpen(btn, false);
163
+ assert.equal(btn.textContent, "▸");
164
+ assert.equal(btn.getAttribute("aria-expanded"), "false");
165
+ });
166
+
167
+ test("#333: chevronToggle falls back to a generic accessible name when none is given", (t) => {
168
+ const created: FakeElement[] = [];
169
+ t.after(installFakeDom(created));
170
+ const btn = chevronToggle();
171
+ assert.equal(btn.getAttribute("aria-label"), "Toggle details");
172
+ });
173
+
174
+ test("#333: submitting POSTs the interpolated {{row.*}}/{{form.*}} body, shows successLabel, and fires onSuccess", async (t) => {
175
+ const created: FakeElement[] = [];
176
+ t.after(installFakeDom(created));
177
+ /** @type {Array<{ url: unknown; init: any }>} */
178
+ const calls: Array<{ url: unknown; init: { method?: string; body?: string } }> = [];
179
+ const priorFetch = globalThis.fetch;
180
+ Reflect.set(globalThis, "fetch", async (url: unknown, init: { method?: string; body?: string }) => {
181
+ calls.push({ url, init });
182
+ return { ok: true, status: 200, json: async () => ({ ok: true }) };
183
+ });
184
+ t.after(() => Reflect.set(globalThis, "fetch", priorFetch));
185
+
186
+ let refreshed = 0;
187
+ const box = buildDetailForm(FORM, { answerable: true, question: "Ship it?", task_id: "T-42" }, () => {
188
+ refreshed++;
189
+ });
190
+ assert.ok(box);
191
+ const textarea = created.find((n) => n.tagName === "TEXTAREA");
192
+ const button = created.find((n) => n.tagName === "BUTTON");
193
+ assert.ok(textarea && button);
194
+ textarea!.value = "Looks good, proceed";
195
+ await button!.fire("click");
196
+
197
+ assert.equal(calls.length, 1, "exactly one route POST");
198
+ assert.equal(calls[0].init.method, "POST");
199
+ const sent = JSON.parse(String(calls[0].init.body));
200
+ assert.deepEqual(
201
+ sent,
202
+ { taskId: "T-42", variables: { comment: "Looks good, proceed" } },
203
+ "row token → task_id, whole-form splice → { comment }",
204
+ );
205
+ assert.equal(refreshed, 1, "onSuccess (child-grid reload / page re-poll) fires once");
206
+ const msg = created.find((n) => n.tagName === "P" && n.className.includes("ok"));
207
+ assert.equal(msg?.textContent, "Answered ✓", "the action successLabel is shown");
208
+ });
209
+
210
+ test("#333: a failed submit surfaces the error, re-enables submit, and does NOT fire onSuccess", async (t) => {
211
+ const created: FakeElement[] = [];
212
+ t.after(installFakeDom(created));
213
+ const priorFetch = globalThis.fetch;
214
+ Reflect.set(globalThis, "fetch", async () => ({ ok: false, status: 500, json: async () => ({ error: "boom" }) }));
215
+ t.after(() => Reflect.set(globalThis, "fetch", priorFetch));
216
+
217
+ let refreshed = 0;
218
+ const box = buildDetailForm(FORM, { answerable: true, question: "Ship it?", task_id: "T-9" }, () => {
219
+ refreshed++;
220
+ });
221
+ assert.ok(box);
222
+ const button = created.find((n) => n.tagName === "BUTTON");
223
+ await button!.fire("click");
224
+
225
+ assert.equal(refreshed, 0, "onSuccess must not fire on failure — the row stays");
226
+ assert.equal(button?.disabled, false, "submit is re-enabled so the operator can retry");
227
+ const msg = created.find((n) => n.tagName === "P" && n.className.includes("err"));
228
+ assert.equal(msg?.textContent, "boom", "the server error is surfaced");
229
+ });
230
+
231
+ test("#333: a failed onSuccess refresh is surfaced and re-enables submit — no silent failure / stuck button", async (t) => {
232
+ const created: FakeElement[] = [];
233
+ t.after(installFakeDom(created));
234
+ const priorFetch = globalThis.fetch;
235
+ Reflect.set(globalThis, "fetch", async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }));
236
+ t.after(() => Reflect.set(globalThis, "fetch", priorFetch));
237
+
238
+ // The submit itself succeeds, but the follow-up refresh (a child grid's
239
+ // re-fetch) rejects. buildDetailForm awaits onSuccess, so the reload failure
240
+ // must NOT be swallowed: the error is shown and the button re-enabled, instead
241
+ // of the operator being stuck on a permanently-disabled button under a false
242
+ // "Sent". Guards both suppressed advisories on runtime.browser.js:1581/1807.
243
+ const box = buildDetailForm(FORM, { answerable: true, question: "Ship it?", task_id: "T-7" }, async () => {
244
+ throw new Error("reload failed");
245
+ });
246
+ assert.ok(box);
247
+ const button = created.find((n) => n.tagName === "BUTTON");
248
+ await button!.fire("click");
249
+
250
+ assert.equal(button?.disabled, false, "submit re-enabled when the refresh fails so the operator isn't stuck");
251
+ const err = created.find((n) => n.tagName === "P" && n.className.includes("err"));
252
+ assert.equal(err?.textContent, "reload failed", "the refresh failure is surfaced, not swallowed");
253
+ });
254
+
255
+ test("#333: on a fully successful submit the button stays disabled — the refresh detaches/rebuilds the form", async (t) => {
256
+ const created: FakeElement[] = [];
257
+ t.after(installFakeDom(created));
258
+ const priorFetch = globalThis.fetch;
259
+ Reflect.set(globalThis, "fetch", async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }));
260
+ t.after(() => Reflect.set(globalThis, "fetch", priorFetch));
261
+
262
+ const box = buildDetailForm(FORM, { answerable: true, question: "Ship it?", task_id: "T-1" }, () => {});
263
+ assert.ok(box);
264
+ const button = created.find((n) => n.tagName === "BUTTON");
265
+ await button!.fire("click");
266
+
267
+ assert.equal(button?.disabled, true, "left disabled on success — the refresh replaces the form, so re-enabling is moot");
268
+ });
@@ -359,3 +359,39 @@ export interface EngineClient {
359
359
  /** Tear down all connections. */
360
360
  close(): Promise<void>;
361
361
  }
362
+
363
+ /**
364
+ * The names of every method on {@link EngineClient} — the single *runtime* source of
365
+ * truth for the interface's surface. It is compile-time-pinned to `keyof EngineClient`
366
+ * in both directions (see the two assertions below): the `satisfies` rejects a stray or
367
+ * misspelled name, and the exhaustiveness assertion rejects an *omitted* one, so adding,
368
+ * renaming, or removing an `EngineClient` method that this list does not mirror fails
369
+ * `npm run typecheck`.
370
+ *
371
+ * A fake/adapter (e.g. `@nanobpm/urban-testkit`'s `WasmEngineClient`) can iterate this
372
+ * list to assert at *runtime* that it implements the full surface — catching the
373
+ * "the SDK grew a method the test double lags" seam-lag class (issue #341: `openUserTasks`,
374
+ * and `getForm` before it) that a purely structural `implements EngineClient` check no
375
+ * longer catches once the fake is published/compiled against an older `@nanobpm/urban`.
376
+ */
377
+ export const ENGINE_CLIENT_METHODS = [
378
+ "deployResources",
379
+ "createInstance",
380
+ "cancelInstance",
381
+ "publishMessage",
382
+ "searchUserTasks",
383
+ "openUserTasks",
384
+ "getForm",
385
+ "completeUserTask",
386
+ "searchProcessInstances",
387
+ "registerWorker",
388
+ "close",
389
+ ] as const satisfies readonly (keyof EngineClient)[];
390
+
391
+ // Compile-time exhaustiveness: every `EngineClient` method must appear in
392
+ // `ENGINE_CLIENT_METHODS`. If one is omitted, `Exclude<...>` is a non-`never` union of
393
+ // the missing keys, so this type resolves to `never` and the `true` assignment fails to
394
+ // compile — the twin of the `satisfies` above, together pinning the list to
395
+ // `keyof EngineClient` exactly (No Drift Surfaces).
396
+ type MissingEngineClientMethods = Exclude<keyof EngineClient, (typeof ENGINE_CLIENT_METHODS)[number]>;
397
+ const _engineClientMethodsAreExhaustive: [MissingEngineClientMethods] extends [never] ? true : never = true;