@nanobpm/urban 0.55.0 → 0.57.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 (37) hide show
  1. package/dist/context/adversarial/index.d.ts +1 -0
  2. package/dist/context/adversarial/index.js +1 -0
  3. package/dist/context/binding/index.d.ts +1 -0
  4. package/dist/context/binding/index.js +1 -0
  5. package/dist/context/conformance/index.d.ts +1 -0
  6. package/dist/context/conformance/index.js +1 -0
  7. package/dist/context/conformance/scaffold.conformance.d.ts +1 -0
  8. package/dist/context/conformance/scaffold.conformance.js +10 -0
  9. package/dist/context/git/index.d.ts +1 -0
  10. package/dist/context/git/index.js +1 -0
  11. package/dist/context/index.d.ts +6 -0
  12. package/dist/context/index.js +18 -0
  13. package/dist/context/pii/index.d.ts +1 -0
  14. package/dist/context/pii/index.js +1 -0
  15. package/dist/context/retrieval/index.d.ts +1 -0
  16. package/dist/context/retrieval/index.js +1 -0
  17. package/dist/context/schema/index.d.ts +1 -0
  18. package/dist/context/schema/index.js +1 -0
  19. package/dist/runtime/core/modules/pages.js +5 -1917
  20. package/dist/runtime/core/modules/runtime.gen.d.ts +1 -0
  21. package/dist/runtime/core/modules/runtime.gen.js +5 -0
  22. package/package.json +36 -3
  23. package/src/context/README.md +60 -0
  24. package/src/context/adversarial/index.ts +2 -0
  25. package/src/context/binding/index.ts +2 -0
  26. package/src/context/conformance/index.ts +2 -0
  27. package/src/context/conformance/scaffold.conformance.ts +11 -0
  28. package/src/context/git/index.ts +2 -0
  29. package/src/context/index.ts +19 -0
  30. package/src/context/pii/index.ts +2 -0
  31. package/src/context/retrieval/index.ts +2 -0
  32. package/src/context/schema/index.ts +2 -0
  33. package/src/runtime/browser/runtime.browser.js +2179 -0
  34. package/src/runtime/browser/runtime.browser.test.ts +118 -0
  35. package/src/runtime/core/modules/pages.test.ts +73 -7
  36. package/src/runtime/core/modules/pages.ts +5 -1917
  37. package/src/runtime/core/modules/runtime.gen.ts +6 -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
+ });
@@ -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";
@@ -500,7 +504,7 @@ test("renderer binds a route param: parseRoute splits page/param, filters + text
500
504
  assert.match(js, /const slash = raw\.indexOf\("\/"\)/);
501
505
  assert.match(js, /const paramRaw = slash >= 0 \? raw\.slice\(slash \+ 1\) : ""/);
502
506
  assert.match(js, /param = paramRaw \? decodeURIComponent\(paramRaw\) : ""/);
503
- assert.match(js, /let PARAM = parseRoute\(\)\.param/);
507
+ assert.match(js, /PARAM = parseRoute\(\)\.param/);
504
508
  // renderPage refreshes PARAM from the current route on every (re)render.
505
509
  assert.match(js, /const route = parseRoute\(\);\s*CURRENT = route\.page;\s*PARAM = route\.param;/s);
506
510
  // A datasource filter with { eqParam: true } binds its value to the live PARAM,
@@ -584,7 +588,7 @@ test("renderer exposes an embed-gated host-navigation bridge", async () => {
584
588
  // data can't smuggle a path. The same-origin gate reads parent.location.origin
585
589
  // in a try/catch (a cross-origin parent throws under the same-origin policy).
586
590
  assert.match(js, /window\.parent\.location\.origin === window\.location\.origin/);
587
- assert.match(js, /const NANO_EMBEDDED =/);
591
+ assert.match(js, /NANO_EMBEDDED = computeEmbedded\(\)/);
588
592
  assert.match(js, /function hostNavigate\(target, params\)/);
589
593
  assert.match(js, /if \(!NANO_EMBEDDED\) return false;/);
590
594
  assert.match(
@@ -592,7 +596,7 @@ test("renderer exposes an embed-gated host-navigation bridge", async () => {
592
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*\)/,
593
597
  );
594
598
  // The theme bridge reuses the same embed flag (no second window.parent probe).
595
- assert.match(js, /if \(NANO_EMBEDDED\) \{/);
599
+ assert.match(js, /function installThemeBridge\(\)\s*\{\s*if \(!NANO_EMBEDDED\) return;/);
596
600
  });
597
601
 
598
602
  test("the renderer honours numeric actionForm fields", async () => {
@@ -690,7 +694,7 @@ test("renderer makes collapsible nodes persist their state across sessions", asy
690
694
  assert.match(js, /localStorage\.setItem\(key, val \? "1" : "0"\)/);
691
695
  assert.match(js, /catch \(e\) \{\s*return dflt;/);
692
696
  // And the wrapper is actually applied at the dispatch layer.
693
- assert.match(js, /makeCollapsible\(n, \(RENDERERS\[n\.type\]/);
697
+ assert.match(js, /makeCollapsible\(n, \(\/\*\* @type \{Record<string, Renderer>\} \*\/ \(RENDERERS\)\[n\.type\]/);
694
698
  // Non-card renderer output (e.g. text -> <p>) is nested whole inside a fresh
695
699
  // <section class="pc-card"> rather than having a <button>/<div> injected into
696
700
  // it — so the feature is valid markup across every node type, not just cards.
@@ -997,9 +1001,8 @@ test("the prose renderer ships a safe, dependency-free markdown → DOM converte
997
1001
  // Underscore emphasis is word-boundary guarded so snake_case identifiers survive.
998
1002
  assert.match(js, /function mdIsWordChar\(c\)/);
999
1003
  assert.match(js, /!mdIsWordChar\(s\[i - 1\]\)/);
1000
- // The code backtick is built from a char code so this source stays inside the
1001
- // String.raw template literal.
1002
- 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 = "`"/);
1003
1006
  });
1004
1007
 
1005
1008
  test("the shell styles the prose/markdown list at a comfortable reading measure (#274)", async () => {
@@ -1471,3 +1474,66 @@ test("shell CSS carries the .pc-pipe* track chrome reusing --nano-* tokens", asy
1471
1474
  assert.match(html, /\.pc-pipe-skip \{[^}]*border-style:dashed/);
1472
1475
  assert.match(html, /\.pc-pipe-upcoming \{[^}]*var\(--nano-text-faint\)/);
1473
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
+ });