@nanobpm/urban 0.55.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.
- package/dist/runtime/core/modules/pages.js +5 -1917
- package/dist/runtime/core/modules/runtime.gen.d.ts +1 -0
- package/dist/runtime/core/modules/runtime.gen.js +5 -0
- package/package.json +5 -3
- package/src/runtime/browser/runtime.browser.js +2179 -0
- package/src/runtime/browser/runtime.browser.test.ts +118 -0
- package/src/runtime/core/modules/pages.test.ts +73 -7
- package/src/runtime/core/modules/pages.ts +5 -1917
- 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, /
|
|
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, /
|
|
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
|
|
1001
|
-
|
|
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
|
+
});
|