@nanobpm/urban 0.54.0 → 0.56.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.
Files changed (29) hide show
  1. package/dist/runtime/core/host.d.ts +31 -5
  2. package/dist/runtime/core/modules/pages.js +5 -1850
  3. package/dist/runtime/core/modules/runtime.gen.d.ts +1 -0
  4. package/dist/runtime/core/modules/runtime.gen.js +5 -0
  5. package/dist/runtime/engine/nanosdk.d.ts +6 -5
  6. package/dist/runtime/engine/nanosdk.js +6 -0
  7. package/dist/runtime/index.d.ts +1 -1
  8. package/package.json +5 -3
  9. package/src/runtime/browser/runtime.browser.js +2179 -0
  10. package/src/runtime/browser/runtime.browser.test.ts +118 -0
  11. package/src/runtime/core/host.ts +32 -5
  12. package/src/runtime/core/modules/actions.test.ts +1 -0
  13. package/src/runtime/core/modules/agent.test.ts +1 -0
  14. package/src/runtime/core/modules/api.test.ts +1 -0
  15. package/src/runtime/core/modules/cancel.test.ts +1 -0
  16. package/src/runtime/core/modules/connectors.test.ts +1 -0
  17. package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
  18. package/src/runtime/core/modules/instance-tracking.test.ts +1 -0
  19. package/src/runtime/core/modules/pages.test.ts +114 -7
  20. package/src/runtime/core/modules/pages.ts +5 -1850
  21. package/src/runtime/core/modules/runtime.gen.ts +6 -0
  22. package/src/runtime/core/modules/surfaces.test.ts +2 -0
  23. package/src/runtime/core/modules/triggers.test.ts +1 -0
  24. package/src/runtime/core/modules/workers.test.ts +1 -0
  25. package/src/runtime/core/runtime.extensions.test.ts +3 -0
  26. package/src/runtime/core/runtime.test.ts +3 -0
  27. package/src/runtime/engine/nanosdk.test.ts +18 -0
  28. package/src/runtime/engine/nanosdk.ts +9 -4
  29. package/src/runtime/index.ts +1 -0
@@ -0,0 +1,118 @@
1
+ // Direct unit tests for the browser page runtime (#291). Because the runtime is
2
+ // now REAL importable source — not a `String.raw` blob — we can import its
3
+ // renderer functions and its RENDERERS registry and drive them with a minimal
4
+ // fake DOM, instead of string-scraping the served module. This replaces the #290
5
+ // regex drift guard (which had to regex the RENDERERS keys out of a string) with
6
+ // a real import, and adds the first true renderer-level unit coverage.
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { PAGE_NODE_TYPES } from "@nanobpm/nano-app-schema";
10
+ import { RENDERERS, renderText } from "./runtime.browser.js";
11
+
12
+ // ── Minimal fake DOM ────────────────────────────────────────────────────────
13
+ // Just enough of the Element/Document surface that el()/renderText touch:
14
+ // createElement, className, setAttribute, addEventListener, append (of child
15
+ // nodes and text), and a readable textContent. Installed on globalThis so the
16
+ // module's global `document` resolves to it when a renderer is invoked. The
17
+ // module itself performs NO DOM access at import time (its boot() is gated), so
18
+ // installing this only needs to happen before we CALL a renderer.
19
+ class FakeElement {
20
+ tagName: string;
21
+ attributes: Record<string, string> = {};
22
+ className = "";
23
+ children: Array<FakeElement | { text: string }> = [];
24
+ textContent = "";
25
+ dataset: Record<string, string> = {};
26
+ hidden = false;
27
+ style = { setProperty() {} };
28
+
29
+ constructor(tag: string) {
30
+ this.tagName = String(tag).toUpperCase();
31
+ }
32
+ setAttribute(k: string, v: unknown): void {
33
+ this.attributes[k] = String(v);
34
+ if (k === "class") this.className = String(v);
35
+ }
36
+ getAttribute(k: string): string | null {
37
+ return k in this.attributes ? this.attributes[k] : null;
38
+ }
39
+ addEventListener(): void {}
40
+ append(...kids: Array<FakeElement | string>): void {
41
+ for (const kid of kids) {
42
+ if (typeof kid === "string") {
43
+ this.children.push({ text: kid });
44
+ this.textContent += kid;
45
+ } else {
46
+ this.children.push(kid);
47
+ this.textContent += kid.textContent || "";
48
+ }
49
+ }
50
+ }
51
+ querySelector(): null {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ // Installs the fake DOM on globalThis and returns a restore function that undoes
57
+ // the mutation, so a test can register `t.after(restore)` and never leak
58
+ // `globalThis.document` into later tests (which would be racy if node:test ran
59
+ // files/tests concurrently). Captures the prior descriptor so the original
60
+ // value — including `undefined` — is faithfully restored.
61
+ function installFakeDom(): () => void {
62
+ const doc = {
63
+ createElement: (tag: string) => new FakeElement(tag),
64
+ getElementById: () => null,
65
+ };
66
+ const prior = Reflect.getOwnPropertyDescriptor(globalThis, "document");
67
+ // Reflect.set writes through the untyped host boundary (globalThis.document is
68
+ // typed Document under the DOM lib; our fake only implements what the renderer
69
+ // touches) without an `as` cast (banned repo-wide).
70
+ Reflect.set(globalThis, "document", doc);
71
+ return () => {
72
+ if (prior) {
73
+ Reflect.defineProperty(globalThis, "document", prior);
74
+ } else {
75
+ Reflect.deleteProperty(globalThis, "document");
76
+ }
77
+ };
78
+ }
79
+
80
+ test("#291: RENDERERS is a real importable registry keyed exactly by PAGE_NODE_TYPES", () => {
81
+ // The drift lock is enforced at compile time by `RENDERERS satisfies
82
+ // Record<PageNodeType, Renderer>` in the source; this asserts the same at
83
+ // runtime via a real import (no string-scraping of the served module). If the
84
+ // schema gains a page-node type without a renderer here (or vice-versa) this
85
+ // fails loudly — the App would otherwise render an accepted node as a blank div.
86
+ assert.deepEqual(
87
+ Object.keys(RENDERERS).sort(),
88
+ [...PAGE_NODE_TYPES].sort(),
89
+ "RENDERERS keys must equal the shared PAGE_NODE_TYPES registry",
90
+ );
91
+ for (const [type, renderer] of Object.entries(RENDERERS)) {
92
+ assert.equal(typeof renderer, "function", `renderer for "${type}" must be a function`);
93
+ }
94
+ });
95
+
96
+ test("#291: renderText renders the heading/sub/body variant with the right tag + class", (t) => {
97
+ t.after(installFakeDom());
98
+ const heading = renderText({ type: "text", props: { variant: "heading", text: "Hello" } });
99
+ assert.equal(heading.tagName, "H1");
100
+ assert.equal(heading.className, "pc-heading");
101
+ assert.equal(heading.textContent, "Hello");
102
+
103
+ const sub = renderText({ type: "text", props: { variant: "sub", text: "Subtitle" } });
104
+ assert.equal(sub.tagName, "P");
105
+ assert.equal(sub.className, "pc-sub");
106
+
107
+ const body = renderText({ type: "text", props: { text: "Body copy" } });
108
+ assert.equal(body.tagName, "P");
109
+ assert.equal(body.className, "pc-body");
110
+ assert.equal(body.textContent, "Body copy");
111
+ });
112
+
113
+ test("#291: renderText degrades to empty text (never throws) on a missing text prop", (t) => {
114
+ t.after(installFakeDom());
115
+ const node = renderText({ type: "text", props: {} });
116
+ assert.equal(node.tagName, "P");
117
+ assert.equal(node.textContent, "");
118
+ });
@@ -249,6 +249,18 @@ export interface UserTaskSummary {
249
249
  readonly externalFormReference?: string;
250
250
  }
251
251
 
252
+ /**
253
+ * The lifecycle-state-agnostic selectors for a user-task search: which process instance,
254
+ * assignee, and/or candidate group to match. Shared by {@link EngineClient.searchUserTasks}
255
+ * (which adds an optional `state`) and {@link EngineClient.openUserTasks} (which pins
256
+ * `state: "CREATED"` for you), so the two accessors cannot drift on how a task is selected.
257
+ */
258
+ export interface UserTaskFilter {
259
+ processInstanceKey?: string;
260
+ assignee?: string;
261
+ candidateGroup?: string;
262
+ }
263
+
252
264
  /**
253
265
  * A deployed form's form-js schema plus its identifying metadata, as
254
266
  * {@link EngineClient.getForm} returns it. `schema` is the parsed form-js document
@@ -292,13 +304,28 @@ export interface EngineClient {
292
304
  * By default this is unfiltered by lifecycle state and may return tasks in any state
293
305
  * (e.g. completed/canceled); pass `state: "CREATED"` to constrain the search to open
294
306
  * (answerable) tasks. Each result carries the task's resolved form linkage
295
- * (`formKey`/`externalFormReference`) when present. */
296
- searchUserTasks(filter?: {
297
- processInstanceKey?: string;
298
- assignee?: string;
299
- candidateGroup?: string;
307
+ * (`formKey`/`externalFormReference`) when present.
308
+ *
309
+ * **Footgun:** because this defaults to *any* lifecycle state and lags a completion,
310
+ * a just-answered task is still returned for a moment — so treating mere *existence*
311
+ * of a result as "open / actionable" projects a completed task as if a human could
312
+ * still act on it (a torn read-model row). {@link UserTaskSummary} deliberately omits
313
+ * `state`, so a caller cannot re-filter results defensively. If you are deciding
314
+ * whether a human can act — any reconcile / affordance path — use
315
+ * {@link EngineClient.openUserTasks} instead; reserve `searchUserTasks` for the rare
316
+ * legitimate "any lifecycle state" audit/read case. */
317
+ searchUserTasks(filter?: UserTaskFilter & {
300
318
  state?: UserTaskState;
301
319
  }): Promise<UserTaskSummary[]>;
320
+ /** The user tasks that are actually *open* (answerable) — semantically the accessor to
321
+ * reach for whenever you are deciding whether a human can act (reconcile / affordance
322
+ * paths). A thin, intent-named wrapper that pins the lifecycle-state invariant the SDK
323
+ * documents: exactly `searchUserTasks({ ...filter, state: "CREATED" })`, since `CREATED`
324
+ * is the only open state ({@link UserTaskState}). Unlike a bare `searchUserTasks`, it
325
+ * cannot surface a completed/canceled task as if it were still actionable — the footgun
326
+ * described on `searchUserTasks`. Same selectors as `searchUserTasks` minus `state`
327
+ * (pinned to `CREATED`). */
328
+ openUserTasks(filter?: UserTaskFilter): Promise<UserTaskSummary[]>;
302
329
  /**
303
330
  * Fetch a deployed form's form-js schema for the `taskInbox` surface. Resolve by
304
331
  * `formKey` (the linkage the engine attaches to a user task) or, as a best-effort
@@ -29,6 +29,7 @@ const engine: EngineClient = {
29
29
  cancelInstance: async () => {},
30
30
  publishMessage: async () => {},
31
31
  searchUserTasks: async () => [],
32
+ openUserTasks: async () => [],
32
33
  getForm: async () => null,
33
34
  completeUserTask: async () => {},
34
35
  searchProcessInstances: async () => [],
@@ -13,6 +13,7 @@ const fakeEngine: EngineClient = {
13
13
  cancelInstance: async () => {},
14
14
  publishMessage: async () => {},
15
15
  searchUserTasks: async () => [],
16
+ openUserTasks: async () => [],
16
17
  getForm: async () => null,
17
18
  completeUserTask: async () => {},
18
19
  searchProcessInstances: async () => [],
@@ -28,6 +28,7 @@ const engine: EngineClient = {
28
28
  cancelInstance: async () => {},
29
29
  publishMessage: async () => {},
30
30
  searchUserTasks: async () => [],
31
+ openUserTasks: async () => [],
31
32
  getForm: async () => null,
32
33
  searchProcessInstances: async () => [],
33
34
  completeUserTask: async () => {},
@@ -73,6 +73,7 @@ function cancelEngine(opts: {
73
73
  createInstance: notUsed("createInstance"),
74
74
  publishMessage: notUsed("publishMessage"),
75
75
  searchUserTasks: notUsed("searchUserTasks"),
76
+ openUserTasks: notUsed("openUserTasks"),
76
77
  getForm: notUsed("getForm"),
77
78
  completeUserTask: notUsed("completeUserTask"),
78
79
  registerWorker: notUsed("registerWorker"),
@@ -17,6 +17,7 @@ class MiniEngine implements EngineClient {
17
17
  async cancelInstance(): Promise<void> {}
18
18
  async publishMessage(): Promise<void> {}
19
19
  async searchUserTasks(): Promise<[]> { return []; }
20
+ async openUserTasks(): Promise<[]> { return []; }
20
21
  async getForm(): Promise<null> { return null; }
21
22
  async completeUserTask(): Promise<void> {}
22
23
  async searchProcessInstances(): Promise<[]> { return []; }
@@ -16,6 +16,7 @@ const noEngine: EngineClient = {
16
16
  cancelInstance: async () => {},
17
17
  publishMessage: async () => {},
18
18
  searchUserTasks: async () => [],
19
+ openUserTasks: async () => [],
19
20
  getForm: async () => null,
20
21
  completeUserTask: async () => {},
21
22
  searchProcessInstances: async () => [],
@@ -96,6 +96,7 @@ function fakeEngine(states: Record<string, ProcessInstanceSnapshot["state"]>): {
96
96
  cancelInstance: notUsed("cancelInstance"),
97
97
  publishMessage: notUsed("publishMessage"),
98
98
  searchUserTasks: notUsed("searchUserTasks"),
99
+ openUserTasks: notUsed("openUserTasks"),
99
100
  getForm: notUsed("getForm"),
100
101
  completeUserTask: notUsed("completeUserTask"),
101
102
  registerWorker: notUsed("registerWorker"),
@@ -1,5 +1,9 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import { execFileSync } from "node:child_process";
4
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
3
7
  import type { EngineClient, HttpRequest, HttpResponse } from "../host.ts";
4
8
  import { makeRouter } from "../router.ts";
5
9
  import { createPagesRoutes, type PagesDataSource, type PagesDeps } from "./pages.ts";
@@ -40,6 +44,7 @@ function fakeEngine(): { engine: EngineClient; calls: FakeEngineCalls } {
40
44
  calls.messages.push(input);
41
45
  },
42
46
  async searchUserTasks() { return []; },
47
+ async openUserTasks() { return []; },
43
48
  async getForm() { return null; },
44
49
  async completeUserTask() {},
45
50
  async searchProcessInstances() { return []; },
@@ -499,7 +504,7 @@ test("renderer binds a route param: parseRoute splits page/param, filters + text
499
504
  assert.match(js, /const slash = raw\.indexOf\("\/"\)/);
500
505
  assert.match(js, /const paramRaw = slash >= 0 \? raw\.slice\(slash \+ 1\) : ""/);
501
506
  assert.match(js, /param = paramRaw \? decodeURIComponent\(paramRaw\) : ""/);
502
- assert.match(js, /let PARAM = parseRoute\(\)\.param/);
507
+ assert.match(js, /PARAM = parseRoute\(\)\.param/);
503
508
  // renderPage refreshes PARAM from the current route on every (re)render.
504
509
  assert.match(js, /const route = parseRoute\(\);\s*CURRENT = route\.page;\s*PARAM = route\.param;/s);
505
510
  // A datasource filter with { eqParam: true } binds its value to the live PARAM,
@@ -583,7 +588,7 @@ test("renderer exposes an embed-gated host-navigation bridge", async () => {
583
588
  // data can't smuggle a path. The same-origin gate reads parent.location.origin
584
589
  // in a try/catch (a cross-origin parent throws under the same-origin policy).
585
590
  assert.match(js, /window\.parent\.location\.origin === window\.location\.origin/);
586
- assert.match(js, /const NANO_EMBEDDED =/);
591
+ assert.match(js, /NANO_EMBEDDED = computeEmbedded\(\)/);
587
592
  assert.match(js, /function hostNavigate\(target, params\)/);
588
593
  assert.match(js, /if \(!NANO_EMBEDDED\) return false;/);
589
594
  assert.match(
@@ -591,7 +596,7 @@ test("renderer exposes an embed-gated host-navigation bridge", async () => {
591
596
  /window\.parent\.postMessage\(\s*\{\s*type:\s*"nano-navigate",\s*target:\s*target,\s*params:\s*params\s*\}\s*,\s*window\.location\.origin\s*\)/,
592
597
  );
593
598
  // The theme bridge reuses the same embed flag (no second window.parent probe).
594
- assert.match(js, /if \(NANO_EMBEDDED\) \{/);
599
+ assert.match(js, /function installThemeBridge\(\)\s*\{\s*if \(!NANO_EMBEDDED\) return;/);
595
600
  });
596
601
 
597
602
  test("the renderer honours numeric actionForm fields", async () => {
@@ -689,7 +694,7 @@ test("renderer makes collapsible nodes persist their state across sessions", asy
689
694
  assert.match(js, /localStorage\.setItem\(key, val \? "1" : "0"\)/);
690
695
  assert.match(js, /catch \(e\) \{\s*return dflt;/);
691
696
  // And the wrapper is actually applied at the dispatch layer.
692
- assert.match(js, /makeCollapsible\(n, \(RENDERERS\[n\.type\]/);
697
+ assert.match(js, /makeCollapsible\(n, \(\/\*\* @type \{Record<string, Renderer>\} \*\/ \(RENDERERS\)\[n\.type\]/);
693
698
  // Non-card renderer output (e.g. text -> <p>) is nested whole inside a fresh
694
699
  // <section class="pc-card"> rather than having a <button>/<div> injected into
695
700
  // it — so the feature is valid markup across every node type, not just cards.
@@ -891,6 +896,46 @@ test("the renderer ships a button node that opens a copy-pasteable modal", async
891
896
  });
892
897
 
893
898
 
899
+ test("row-action confirm/error route through the in-DOM modal, never native confirm()/alert() (#276)", async () => {
900
+ // A sandboxed iframe without `allow-modals` makes window.confirm() return false
901
+ // WITHOUT prompting and turns alert()/prompt() into no-ops — so every
902
+ // confirm-guarded row action silently dead-ended (the !confirm(...) guard was
903
+ // always true → fireAction never ran) and its errors vanished. The fix routes
904
+ // both through urban's in-DOM openModal overlay, which works with no host
905
+ // sandbox change.
906
+ const res = await dispatch("GET", "/app/runtime.js");
907
+ const js = res.body ?? "";
908
+ // The confirm gate awaits an in-DOM dialog and only proceeds on a real choice.
909
+ assert.match(js, /if \(ra\.confirm && !\(await confirmModal\(ra\.confirm\)\)\) return/);
910
+ // Errors surface through the in-DOM alert instead of the suppressed native one.
911
+ assert.match(js, /alertModal\(String\(e\.message \|\| e\)\)/);
912
+ // The primitives exist and are built on the shared openModal overlay (no drift).
913
+ assert.match(js, /function confirmModal\(message\)/);
914
+ assert.match(js, /return new Promise\(\(resolve\) => \{/);
915
+ assert.match(js, /function alertModal\(message\)/);
916
+ // A dropped guarded open (a modal already showing) resolves false, never hangs.
917
+ assert.match(js, /if \(!opened\) resolve\(false\)/);
918
+ // openModal grew action buttons + a single-fire result callback so it can back a
919
+ // decision dialog; a dismissal (Escape/backdrop/✕/page switch) resolves the
920
+ // caller's default (dismissValue).
921
+ assert.match(js, /Array\.isArray\(m\.actions\)/);
922
+ assert.match(js, /resultValue = a\.value; close\(\);/);
923
+ // Match the semantic call site only — teardown wraps onResult in a try/catch so
924
+ // a throwing callback can't abort focus restoration, so don't pin the control flow.
925
+ assert.match(js, /onResult\(resultValue\)/);
926
+ // Defect-class guard: NO native confirm()/alert()/prompt() CALL (invoked with a
927
+ // string/identifier/template argument) survives anywhere in the served bundle —
928
+ // they are all false/no-ops under a sandboxed iframe. This also catches qualified
929
+ // calls (window.confirm(...) / globalThis.alert(...)): the lookbehind only excludes
930
+ // a preceding word char (so confirmModal(...) / alertModal(...) don't match), not a
931
+ // preceding ".", so a member call still trips the guard. The argument-start class
932
+ // covers string/template literals and identifier heads including $ and _
933
+ // (e.g. confirm(_msg), alert($msg), prompt(`...`)). Empty-paren mentions in
934
+ // comments (e.g. "native confirm()") are documentation, not calls, so are not matched.
935
+ assert.doesNotMatch(js, /(?<!\w)(?:confirm|alert|prompt)\(\s*["'`$_a-zA-Z]/);
936
+ });
937
+
938
+
894
939
  test("the renderer ships a data-bound prose/markdown list node (#274)", async () => {
895
940
  const res = await dispatch("GET", "/app/runtime.js");
896
941
  const js = res.body ?? "";
@@ -956,9 +1001,8 @@ test("the prose renderer ships a safe, dependency-free markdown → DOM converte
956
1001
  // Underscore emphasis is word-boundary guarded so snake_case identifiers survive.
957
1002
  assert.match(js, /function mdIsWordChar\(c\)/);
958
1003
  assert.match(js, /!mdIsWordChar\(s\[i - 1\]\)/);
959
- // The code backtick is built from a char code so this source stays inside the
960
- // String.raw template literal.
961
- assert.match(js, /const MD_BACKTICK = String\.fromCharCode\(96\)/);
1004
+ // The markdown code backtick is a literal in the standalone runtime source.
1005
+ assert.match(js, /const MD_BACKTICK = "`"/);
962
1006
  });
963
1007
 
964
1008
  test("the shell styles the prose/markdown list at a comfortable reading measure (#274)", async () => {
@@ -1430,3 +1474,66 @@ test("shell CSS carries the .pc-pipe* track chrome reusing --nano-* tokens", asy
1430
1474
  assert.match(html, /\.pc-pipe-skip \{[^}]*border-style:dashed/);
1431
1475
  assert.match(html, /\.pc-pipe-upcoming \{[^}]*var\(--nano-text-faint\)/);
1432
1476
  });
1477
+
1478
+ // NOTE (#291): the RENDERERS ↔ PAGE_NODE_TYPES drift guard no longer string-scrapes
1479
+ // the served module. RENDERERS is now compile-time locked (`satisfies
1480
+ // Record<PageNodeType, Renderer>`) in the real source, and verified at runtime by a
1481
+ // direct-import test in ../../browser/runtime.browser.test.ts — no regex needed.
1482
+
1483
+ function hasStderr(err: unknown): err is { stderr: unknown } {
1484
+ return typeof err === "object" && err !== null && "stderr" in err;
1485
+ }
1486
+
1487
+ // Run `node --check` on a source string by writing it to a temp `.mjs` module and
1488
+ // invoking a child `node` process. Returns the check outcome: `ok` on exit 0, or the
1489
+ // captured stderr diagnostic when node rejects the module's syntax. This is the whole
1490
+ // CI guard — a stray brace / bad token in the served runtime becomes a failing test
1491
+ // here instead of a blank page in a browser that loads `/app/runtime.js`.
1492
+ function nodeCheck(source: string): { ok: boolean; stderr: string } {
1493
+ const dir = mkdtempSync(join(tmpdir(), "urban-runtime-check-"));
1494
+ const file = join(dir, "runtime.mjs");
1495
+ try {
1496
+ writeFileSync(file, source, "utf8");
1497
+ // Spawn the real `node` binary explicitly. `process.execPath` points at the host
1498
+ // runtime — which under `deno test` is the `deno` binary, whose `--check` runs the
1499
+ // module instead of syntax-only parsing (blowing up on `document`). This guard is
1500
+ // specifically `node --check`, so it must invoke `node` under every test runner.
1501
+ execFileSync("node", ["--check", file], { stdio: "pipe" });
1502
+ return { ok: true, stderr: "" };
1503
+ } catch (err) {
1504
+ const stderr = hasStderr(err) ? String(err.stderr ?? "") : String(err);
1505
+ return { ok: false, stderr };
1506
+ } finally {
1507
+ rmSync(dir, { recursive: true, force: true });
1508
+ }
1509
+ }
1510
+
1511
+ test("the served runtime module passes `node --check` (no syntax errors)", async () => {
1512
+ // The browser runtime is authored as a ~99 KB `String.raw` blob (RENDERER_JS) that
1513
+ // is never type-checked, linted, or otherwise parsed at build time — a stray brace or
1514
+ // bad token surfaces only when a browser loads `/app/runtime.js` and the module fails
1515
+ // to evaluate, silently breaking every page action. This guard serves the module
1516
+ // exactly as production does and runs `node --check` on it, so a syntax error fails CI
1517
+ // instead of the browser. Interim step toward #291 (the full architectural fix).
1518
+ const res = await dispatch("GET", "/app/runtime.js");
1519
+ const source = res.body ?? "";
1520
+ assert.ok(source.length > 0, "the runtime module must be served with a body");
1521
+ const result = nodeCheck(source);
1522
+ assert.ok(
1523
+ result.ok,
1524
+ `served runtime module has a syntax error (node --check):\n${result.stderr}`,
1525
+ );
1526
+ });
1527
+
1528
+ test("the syntax guard is red/green: an injected syntax error fails `node --check`", async () => {
1529
+ // Prove the guard actually bites — a guard that can't fail is worthless. We take the
1530
+ // real served module and splice in a deliberate syntax error (an unterminated function
1531
+ // declaration), then assert `node --check` rejects it. This pins the guard against a
1532
+ // future refactor that accidentally makes nodeCheck always pass (e.g. swallowing the
1533
+ // child's non-zero exit), which would let a broken RENDERER_JS sail through CI.
1534
+ const res = await dispatch("GET", "/app/runtime.js");
1535
+ const broken = `${res.body ?? ""}\nfunction pcInjectedSyntaxError( {\n`;
1536
+ const result = nodeCheck(broken);
1537
+ assert.equal(result.ok, false, "an injected syntax error must fail `node --check`");
1538
+ assert.match(result.stderr, /SyntaxError/);
1539
+ });