@nanobpm/urban 0.42.0 → 0.44.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/cancel.d.ts +8 -4
- package/dist/runtime/core/modules/cancel.js +23 -11
- package/dist/runtime/core/modules/pages.js +346 -18
- package/package.json +2 -2
- package/src/runtime/core/modules/cancel.test.ts +28 -0
- package/src/runtime/core/modules/cancel.ts +23 -11
- package/src/runtime/core/modules/pages.test.ts +191 -2
- package/src/runtime/core/modules/pages.ts +346 -18
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
//
|
|
14
14
|
// This primitive keys success on the engine's response, then reconciles off the read model: a
|
|
15
15
|
// non-throwing `cancelInstance` is a committed termination (trusted even if the read model still
|
|
16
|
-
// lags at ACTIVE), while a throw
|
|
17
|
-
//
|
|
18
|
-
// never drift from the poll reconciler's.
|
|
16
|
+
// lags at ACTIVE), while a throw that does not read back a positively-terminal state (still ACTIVE,
|
|
17
|
+
// or absent from the read model) is an honest failure. It reuses the instanceTracking bindings'
|
|
18
|
+
// `onTerminated.set` patch, so the immediate reconcile can never drift from the poll reconciler's.
|
|
19
19
|
|
|
20
20
|
import type { AppApi } from "../context.ts";
|
|
21
21
|
import type { InstanceTracking } from "../manifest.ts";
|
|
@@ -44,10 +44,14 @@ type CancelApi = Pick<AppApi, "engine" | "data" | "log">;
|
|
|
44
44
|
* Outcome rules:
|
|
45
45
|
* - `cancelInstance` returns without throwing → the engine accepted the termination (a committed
|
|
46
46
|
* 204); trust it even if the read model still lags at ACTIVE.
|
|
47
|
-
* - `cancelInstance` throws but the instance reads back
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
47
|
+
* - `cancelInstance` throws but the instance reads back positively terminal (TERMINATED/COMPLETED)
|
|
48
|
+
* → the cancel was effectively idempotent; treat as success. Only a TERMINATED read reconciles a
|
|
49
|
+
* row here (see below); a COMPLETED read intentionally reconciles nothing.
|
|
50
|
+
* - `cancelInstance` throws and the read is NOT positively terminal — still ACTIVE, or "gone"
|
|
51
|
+
* (absent from the read model, which after an engine restart can transiently lack a live
|
|
52
|
+
* instance) → an honest failure: report it and do NOT touch any row (the instance may still be
|
|
53
|
+
* running). A throw is not a committed cancel, and absence from the read model is not proof of
|
|
54
|
+
* termination, so success is never inferred from it.
|
|
51
55
|
*
|
|
52
56
|
* Reconcile only on `TERMINATED`: a COMPLETED instance's terminal row-write is owned by the app's
|
|
53
57
|
* own finalize logic, and a gone/ACTIVE instance has nothing to flip here.
|
|
@@ -68,11 +72,19 @@ export async function cancelInstanceReconciling(
|
|
|
68
72
|
|
|
69
73
|
const state = await readState(api, key, threw);
|
|
70
74
|
|
|
71
|
-
if (threw && state
|
|
72
|
-
// The cancel
|
|
73
|
-
// terminal
|
|
74
|
-
|
|
75
|
+
if (threw && state !== "TERMINATED" && state !== "COMPLETED") {
|
|
76
|
+
// The cancel threw, so we never got a committed 204 — and the verify read did NOT positively
|
|
77
|
+
// confirm a terminal state. Two shapes land here, both honest failures:
|
|
78
|
+
// • state === "ACTIVE": the instance is demonstrably still running.
|
|
79
|
+
// • state === "gone": the read model has no record of the key. A throw makes this
|
|
80
|
+
// ambiguous — after an engine restart the query store can transiently lack a live instance
|
|
81
|
+
// before it rehydrates, so "gone" is absence-of-evidence, NOT proof of termination.
|
|
82
|
+
// Trust neither as success: claiming a cancel that may not have happened (while the run keeps
|
|
83
|
+
// executing) is the worst possible lie for a cancel button. Report ok:false so the caller
|
|
84
|
+
// surfaces it and retries; never flip a tracked row to terminal while the instance may live.
|
|
85
|
+
api.log("error", "cancel: engine termination not confirmed, instance may still be active", {
|
|
75
86
|
processInstanceKey: key,
|
|
87
|
+
state,
|
|
76
88
|
error: errorMessage(threw),
|
|
77
89
|
});
|
|
78
90
|
return { ok: false, processInstanceKey: key, state, reconciled: 0, error: errorMessage(threw) };
|
|
@@ -138,6 +138,36 @@ test("the shell styles the app through the shared --nano-* token contract", asyn
|
|
|
138
138
|
assert.match(html, /:root:not\(\[data-appearance\]\)/);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
test("the warn badge stays legible in light appearance", async () => {
|
|
142
|
+
const res = await dispatch("GET", "/");
|
|
143
|
+
const html = res.body ?? "";
|
|
144
|
+
// The warn badge sits on --nano-warn, which is a bright amber in dark mode
|
|
145
|
+
// (dark text reads well) but a dark orange in light mode (#b45309), where the
|
|
146
|
+
// same dark text collapses to near-zero contrast. Both light paths — the
|
|
147
|
+
// console-themed [data-appearance="light"] and the standalone
|
|
148
|
+
// prefers-color-scheme fallback — must flip the warn label to white so it
|
|
149
|
+
// stays readable.
|
|
150
|
+
assert.match(html, /:root\[data-appearance="light"\]\s*\.pc-badge-warn\s*\{\s*color:#fff;\s*\}/);
|
|
151
|
+
assert.match(
|
|
152
|
+
html,
|
|
153
|
+
/@media \(prefers-color-scheme: light\)\s*\{\s*:root:not\(\[data-appearance\]\)\s*\.pc-badge-warn\s*\{\s*color:#fff;\s*\}\s*\}/,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("grid cells wrap long, space-less values so one cell can't overflow the table", async () => {
|
|
158
|
+
// Regression: a dataGrid cell holding a long space-less value (e.g. a JSON
|
|
159
|
+
// blob or a 40-char SHA) has no soft-wrap opportunity, so with table-layout
|
|
160
|
+
// auto it forces its column to the value's full width and pushes the whole
|
|
161
|
+
// table past the page. The shared grid CSS must let such a cell break so the
|
|
162
|
+
// column can shrink to fit its container.
|
|
163
|
+
const res = await dispatch("GET", "/");
|
|
164
|
+
const html = res.body ?? "";
|
|
165
|
+
const rule = (html.match(/table\.pc-grid th,\s*table\.pc-grid td\s*\{[^}]*\}/) ?? [""])[0];
|
|
166
|
+
assert.ok(rule, "grid th/td rule must be present");
|
|
167
|
+
assert.match(rule, /overflow-wrap:anywhere/);
|
|
168
|
+
assert.match(rule, /max-width:/);
|
|
169
|
+
});
|
|
170
|
+
|
|
141
171
|
test("GET /app/runtime.js serves the renderer module", async () => {
|
|
142
172
|
const res = await dispatch("GET", "/app/runtime.js");
|
|
143
173
|
assert.match(res.headers?.["content-type"] ?? "", /javascript/);
|
|
@@ -290,6 +320,24 @@ test("renderer wires a column's linkField to a new-tab anchor", async () => {
|
|
|
290
320
|
assert.match(js, /rel: "noopener noreferrer"/);
|
|
291
321
|
});
|
|
292
322
|
|
|
323
|
+
test("renderer wires a column's badge to a tone-classed pill shown only when present", async () => {
|
|
324
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
325
|
+
const js = res.body ?? "";
|
|
326
|
+
// A column declaring `badge` renders a compact circular pill (e.g. a red "1"
|
|
327
|
+
// flagging an incident) only when the field value is non-empty; an empty value
|
|
328
|
+
// leaves the cell blank so the column is unobtrusive until it matters. Guard
|
|
329
|
+
// the shape: presence-gated on the trimmed field, tone is allow-listed
|
|
330
|
+
// (default danger), the label defaults to "1", and the full field text becomes
|
|
331
|
+
// the tooltip.
|
|
332
|
+
assert.match(js, /if\s*\(col\.badge\)/);
|
|
333
|
+
assert.match(js, /text\.trim\(\)\s*===\s*""/);
|
|
334
|
+
assert.match(js, /t\s*===\s*"warn"\s*\|\|\s*t\s*===\s*"ok"\s*\|\|\s*t\s*===\s*"info"\s*\?\s*t\s*:\s*"danger"/);
|
|
335
|
+
assert.match(js, /col\.badge\.label\s*==\s*null\s*\?\s*"1"/);
|
|
336
|
+
assert.match(js, /class:\s*"pc-badge pc-badge-"\s*\+\s*tone/);
|
|
337
|
+
assert.match(js, /title:\s*text/);
|
|
338
|
+
assert.match(js, /"aria-label":\s*text/);
|
|
339
|
+
});
|
|
340
|
+
|
|
293
341
|
test("renderer wires a column's processExplorer link to the console explorer", async () => {
|
|
294
342
|
const res = await dispatch("GET", "/app/runtime.js");
|
|
295
343
|
const js = res.body ?? "";
|
|
@@ -316,6 +364,84 @@ test("renderer wires a column's processExplorer link to the console explorer", a
|
|
|
316
364
|
assert.match(js, /ev\.preventDefault\(\)/);
|
|
317
365
|
});
|
|
318
366
|
|
|
367
|
+
test("renderer binds a route param: parseRoute splits page/param, filters + text consume it (#134)", async () => {
|
|
368
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
369
|
+
const js = res.body ?? "";
|
|
370
|
+
// A route is #/<page>/<url-encoded-param>. parseRoute splits on the FIRST "/"
|
|
371
|
+
// BEFORE decoding so a param carrying encoded slashes/hashes (a plan_key like
|
|
372
|
+
// "owner/repo#123") survives; the param segment is decoded verbatim into PARAM.
|
|
373
|
+
assert.match(js, /const slash = raw\.indexOf\("\/"\)/);
|
|
374
|
+
assert.match(js, /const paramRaw = slash >= 0 \? raw\.slice\(slash \+ 1\) : ""/);
|
|
375
|
+
assert.match(js, /param = paramRaw \? decodeURIComponent\(paramRaw\) : ""/);
|
|
376
|
+
assert.match(js, /let PARAM = parseRoute\(\)\.param/);
|
|
377
|
+
// renderPage refreshes PARAM from the current route on every (re)render.
|
|
378
|
+
assert.match(js, /const route = parseRoute\(\);\s*CURRENT = route\.page;\s*PARAM = route\.param;/s);
|
|
379
|
+
// A datasource filter with { eqParam: true } binds its value to the live PARAM,
|
|
380
|
+
// so one page template scopes every section to the selected entity.
|
|
381
|
+
assert.match(js, /else if \(f\.eqParam\) qs\.push\("where=" \+ encodeURIComponent\(f\.field \+ ":" \+ PARAM\)\)/);
|
|
382
|
+
// {{param}} interpolates into text nodes as DOM text (never markup). A function
|
|
383
|
+
// replacement inserts PARAM literally, so replacement patterns inside a param id
|
|
384
|
+
// can't be reinterpreted.
|
|
385
|
+
assert.match(js, /function interpParam\(s\)/);
|
|
386
|
+
assert.match(js, /replace\(\/\\\{\\\{param\\\}\\\}\/g, \(\) => PARAM\)/);
|
|
387
|
+
assert.match(js, /interpParam\(node\.props\.text\)/);
|
|
388
|
+
// A grid whose active filter binds to the route param renders zero rows (no data
|
|
389
|
+
// request) when no param is present, rather than emitting a where clause of
|
|
390
|
+
// field-colon-empty (server reads that as field equals empty string and would
|
|
391
|
+
// surface empty-valued rows).
|
|
392
|
+
assert.match(js, /const paramScoped = \(activeFilter \|\| \[\]\)\.some\(\(f\) => f && f\.eqParam\)/);
|
|
393
|
+
assert.match(js, /paramScoped && PARAM === ""\s*\?\s*\{ rows: \[\] \}/s);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("renderer wires a column's page link to an in-app scoped hash route", async () => {
|
|
397
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
398
|
+
const js = res.body ?? "";
|
|
399
|
+
// link: { kind: "page", page, keyField } → an in-app #/<page>/<key> hash link
|
|
400
|
+
// (no new tab, no server hit). The page id is safe-id-validated, the key is
|
|
401
|
+
// trimmed + URL-encoded into the param tail, and a blank/keyless cell stays text.
|
|
402
|
+
assert.match(js, /col\.link && col\.link\.kind === "page" && col\.link\.page && col\.link\.keyField/);
|
|
403
|
+
assert.match(js, /safePageId\(col\.link\.page\)/);
|
|
404
|
+
assert.match(js, /"#\/" \+ encodeURIComponent\(col\.link\.page\) \+ "\/" \+ encodeURIComponent\(keyStr\)/);
|
|
405
|
+
// In-app: NOT a new-tab link (no target/rel on this anchor).
|
|
406
|
+
assert.match(js, /el\("a", \{ class: "pc-link", href \}, text\)/);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("renderer groups a dataGrid by a field into persisted collapsible bands (waves)", async () => {
|
|
410
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
411
|
+
const js = res.body ?? "";
|
|
412
|
+
// groupBy partitions rows into collapsible groups; each header toggles its
|
|
413
|
+
// members as a unit and the collapsed state is persisted (survives poll+reload).
|
|
414
|
+
assert.match(js, /function appendRows\(rows\)/);
|
|
415
|
+
assert.match(js, /const groupBy = p\.groupBy/);
|
|
416
|
+
assert.match(js, /if \(!groupBy \|\| typeof groupBy !== "string"\) \{ for \(const row of rows\) renderRow\(row, null\); return; \}/);
|
|
417
|
+
// Numeric-aware ascending group order (waves 1,2,10 sort naturally).
|
|
418
|
+
assert.match(js, /const na = Number\(a\), nb = Number\(b\)/);
|
|
419
|
+
// Collapsed state persisted per page + node + group value via the shared helpers.
|
|
420
|
+
assert.match(js, /readCollapsed\(gkey, !!p\.groupDefaultCollapsed\)/);
|
|
421
|
+
assert.match(js, /writeCollapsed\(gkey, gcollapsed\)/);
|
|
422
|
+
assert.match(js, /"pc:collapsed:" \+ CURRENT \+ ":" \+ \(node\.id \|\| p\.title \|\| "grid"\) \+ ":g:" \+ gv/);
|
|
423
|
+
// A collapsed group hides every member row; expanded restores data rows and
|
|
424
|
+
// leaves detail rows at their own expansion state.
|
|
425
|
+
assert.match(js, /m\.tr\.hidden = gcollapsed/);
|
|
426
|
+
assert.match(js, /class: "pc-group-header"/);
|
|
427
|
+
// The group toggle exposes its expanded/collapsed state to assistive tech,
|
|
428
|
+
// mirroring makeCollapsible's header (screen readers announce the change).
|
|
429
|
+
assert.match(js, /btn\.setAttribute\("aria-expanded", String\(!gcollapsed\)\)/);
|
|
430
|
+
// refresh() renders through appendRows so grouping applies on every poll.
|
|
431
|
+
assert.match(js, /tbody\.replaceChildren\(\);\s*appendRows\(rows\);/s);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("renderer supports an opt-in sticky nav bar", async () => {
|
|
435
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
436
|
+
const js = res.body ?? "";
|
|
437
|
+
const css = (await dispatch("GET", "/")).body ?? "";
|
|
438
|
+
// A bar nav with { sticky: true } gets the pc-sticky class (rails are excluded).
|
|
439
|
+
assert.match(js, /const barSticky = !rail && p\.sticky === true/);
|
|
440
|
+
assert.match(js, /\(barSticky \? " pc-sticky" : ""\)/);
|
|
441
|
+
// The CSS pins it to the viewport top with a solid background.
|
|
442
|
+
assert.match(css, /\.pc-bar\.pc-sticky \{[^}]*position:sticky[^}]*top:0/);
|
|
443
|
+
});
|
|
444
|
+
|
|
319
445
|
test("renderer exposes an embed-gated host-navigation bridge", async () => {
|
|
320
446
|
const res = await dispatch("GET", "/app/runtime.js");
|
|
321
447
|
const js = res.body ?? "";
|
|
@@ -441,8 +567,15 @@ test("the renderer routes between pages by hash and tears down grid polls", asyn
|
|
|
441
567
|
assert.match(js, /const PAGE_ID = \/\^\[A-Za-z0-9_-/);
|
|
442
568
|
assert.match(js, /function safePageId\(value\)/);
|
|
443
569
|
assert.match(js, /function currentPage\(\)/);
|
|
444
|
-
|
|
445
|
-
|
|
570
|
+
// The hash is parsed into a page + optional param by parseRoute(); the page
|
|
571
|
+
// segment is decoded and safe-id-validated (falling back to HOME).
|
|
572
|
+
assert.match(js, /function parseRoute\(\)/);
|
|
573
|
+
assert.match(js, /const raw = \(location\.hash \|\| ""\)\.replace/s);
|
|
574
|
+
assert.match(js, /if \(safePageId\(p\)\) \{ page = p; pageOk = true; \}/s);
|
|
575
|
+
// A stray param is only carried when the page segment resolved to a real page,
|
|
576
|
+
// so a malformed/unknown page (#/not-a-page/x) falls back to HOME without
|
|
577
|
+
// inheriting x (which would otherwise silently scope home's eqParam grids).
|
|
578
|
+
assert.match(js, /if \(pageOk\) \{ try \{ param = paramRaw \? decodeURIComponent\(paramRaw\) : ""; \}/s);
|
|
446
579
|
assert.match(js, /location\.hash/);
|
|
447
580
|
assert.match(js, /window\.addEventListener\("hashchange", renderPage\)/);
|
|
448
581
|
assert.match(js, /async function renderPage\(\)/);
|
|
@@ -480,6 +613,62 @@ test("the renderer ships a nav node (menu bar / rail) with in-app + external lin
|
|
|
480
613
|
assert.match(js, /pc-main-col/);
|
|
481
614
|
});
|
|
482
615
|
|
|
616
|
+
test("the renderer ships a button node that opens a copy-pasteable modal", async () => {
|
|
617
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
618
|
+
const js = res.body ?? "";
|
|
619
|
+
// button is a first-class node type.
|
|
620
|
+
assert.match(js, /button: renderButton/);
|
|
621
|
+
assert.match(js, /function renderButton\(node\)/);
|
|
622
|
+
// A ghost variant renders the muted outline style; a button carries its modal.
|
|
623
|
+
assert.match(js, /p\.variant === "ghost"/);
|
|
624
|
+
assert.match(js, /btn\.addEventListener\("click", \(\) => openModal\(m\)\)/);
|
|
625
|
+
// The modal is appended to <body>, is dismissable, and cleans up its keydown.
|
|
626
|
+
assert.match(js, /function openModal\(m\)/);
|
|
627
|
+
assert.match(js, /class: "pc-modal-overlay"/);
|
|
628
|
+
assert.match(js, /"aria-modal": "true"/);
|
|
629
|
+
assert.match(js, /if \(ev\.key === "Escape"\) \{ close\(\); return; \}/);
|
|
630
|
+
assert.match(js, /document\.removeEventListener\("keydown", onKey\)/);
|
|
631
|
+
// {{appBase}} in copy text is rebased onto the absolute mount root so an
|
|
632
|
+
// external agent gets a fetchable URL.
|
|
633
|
+
assert.match(js, /function resolveCopyText\(text\)/);
|
|
634
|
+
assert.match(js, /split\("\{\{appBase\}\}"\)\.join\(APP_BASE\.toString\(\)\)/);
|
|
635
|
+
// Copy is clipboard-first with a sandbox-safe execCommand fallback.
|
|
636
|
+
assert.match(js, /async function copyToClipboard\(text\)/);
|
|
637
|
+
assert.match(js, /navigator\.clipboard\.writeText/);
|
|
638
|
+
assert.match(js, /document\.execCommand\("copy"\)/);
|
|
639
|
+
// The fallback textarea is torn down in a finally so a throwing select()/
|
|
640
|
+
// execCommand never leaves a detached node accreting in <body> on retry.
|
|
641
|
+
assert.match(js, /finally \{ ta\.remove\(\); \}/);
|
|
642
|
+
// The dialog carries an accessible name (labelled by its title, or a fallback
|
|
643
|
+
// aria-label) so screen readers don't announce an unnamed dialog.
|
|
644
|
+
assert.match(js, /"aria-labelledby": titleId/);
|
|
645
|
+
assert.match(js, /"aria-label": "Dialog"/);
|
|
646
|
+
// An open modal is registered with teardown() so a page switch disposes it
|
|
647
|
+
// instead of leaking a stale overlay + document-level keydown listener.
|
|
648
|
+
assert.match(js, /disposers\.push\(close\)/);
|
|
649
|
+
// A manual close removes its own disposer entry, so repeated open/close cycles
|
|
650
|
+
// don't accrete dead close() closures on the disposers stack.
|
|
651
|
+
assert.match(js, /disposers\.indexOf\(close\)/);
|
|
652
|
+
assert.match(js, /disposers\.splice\(i, 1\)/);
|
|
653
|
+
// Focus is restored to the previously-focused element on close, and Tab is
|
|
654
|
+
// trapped inside the dialog so it can't reach the inert page behind the overlay.
|
|
655
|
+
assert.match(js, /const prevFocus = document\.activeElement/);
|
|
656
|
+
assert.match(js, /if \(ev\.key !== "Tab"\) return/);
|
|
657
|
+
assert.match(js, /ev\.preventDefault\(\); last\.focus\(\)/);
|
|
658
|
+
assert.match(js, /ev\.preventDefault\(\); first\.focus\(\)/);
|
|
659
|
+
// Only one modal opens at a time: a second open (e.g. double-click) is dropped
|
|
660
|
+
// so overlays and document-level keydown listeners never stack.
|
|
661
|
+
assert.match(js, /let modalOpen = false/);
|
|
662
|
+
assert.match(js, /if \(modalOpen\) return/);
|
|
663
|
+
assert.match(js, /modalOpen = true/);
|
|
664
|
+
assert.match(js, /modalOpen = false/);
|
|
665
|
+
// The aria-labelledby title id is unique per modal instance so it can't
|
|
666
|
+
// collide with a pre-existing page id and mis-associate the label.
|
|
667
|
+
assert.match(js, /let modalSeq = 0/);
|
|
668
|
+
assert.match(js, /"pc-modal-title-" \+ \(\+\+modalSeq\)/);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
|
|
483
672
|
test("GET /app/data/<source>/<table> returns rows", async () => {
|
|
484
673
|
const res = await dispatch("GET", "/app/data/app/orders");
|
|
485
674
|
assert.equal(res.status, 200);
|