@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.
- package/dist/runtime/core/host.d.ts +31 -5
- package/dist/runtime/core/modules/pages.js +5 -1850
- package/dist/runtime/core/modules/runtime.gen.d.ts +1 -0
- package/dist/runtime/core/modules/runtime.gen.js +5 -0
- package/dist/runtime/engine/nanosdk.d.ts +6 -5
- package/dist/runtime/engine/nanosdk.js +6 -0
- package/dist/runtime/index.d.ts +1 -1
- 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/host.ts +32 -5
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/agent.test.ts +1 -0
- package/src/runtime/core/modules/api.test.ts +1 -0
- package/src/runtime/core/modules/cancel.test.ts +1 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/instance-tracking.test.ts +1 -0
- package/src/runtime/core/modules/pages.test.ts +114 -7
- package/src/runtime/core/modules/pages.ts +5 -1850
- package/src/runtime/core/modules/runtime.gen.ts +6 -0
- package/src/runtime/core/modules/surfaces.test.ts +2 -0
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/runtime.extensions.test.ts +3 -0
- package/src/runtime/core/runtime.test.ts +3 -0
- package/src/runtime/engine/nanosdk.test.ts +18 -0
- package/src/runtime/engine/nanosdk.ts +9 -4
- 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
|
+
});
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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, /
|
|
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, /
|
|
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
|
|
960
|
-
|
|
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
|
+
});
|