@nanobpm/urban 0.46.0 → 0.48.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 +111 -5
- package/dist/toolkit/derivers/shapes.d.ts +5 -0
- package/dist/toolkit/derivers/shapes.js +90 -0
- package/dist/toolkit/derivers/worker-io.d.ts +25 -0
- package/dist/toolkit/derivers/worker-io.js +104 -11
- package/dist/toolkit/gen.js +49 -16
- package/package.json +1 -1
- package/src/runtime/core/modules/pages.test.ts +70 -3
- package/src/runtime/core/modules/pages.ts +111 -5
- package/src/toolkit/derivers/shapes.test.ts +66 -0
- package/src/toolkit/derivers/shapes.ts +93 -0
- package/src/toolkit/derivers/worker-io.test.ts +131 -0
- package/src/toolkit/derivers/worker-io.ts +116 -9
- package/src/toolkit/gen.test.ts +35 -0
- package/src/toolkit/gen.ts +50 -16
|
@@ -337,12 +337,37 @@ test("renderer wires a column's badge to a tone-classed pill shown only when pre
|
|
|
337
337
|
// (default danger), the label defaults to "1", and the full field text becomes
|
|
338
338
|
// the tooltip.
|
|
339
339
|
assert.match(js, /if\s*\(col\.badge\)/);
|
|
340
|
-
assert.match(js, /
|
|
340
|
+
assert.match(js, /rawText\.trim\(\)\s*===\s*""/);
|
|
341
341
|
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"/);
|
|
342
342
|
assert.match(js, /col\.badge\.label\s*==\s*null\s*\?\s*"1"/);
|
|
343
343
|
assert.match(js, /class:\s*"pc-badge pc-badge-"\s*\+\s*tone/);
|
|
344
|
-
assert.match(js, /title:\s*
|
|
345
|
-
assert.match(js, /"aria-label":\s*
|
|
344
|
+
assert.match(js, /title:\s*rawText/);
|
|
345
|
+
assert.match(js, /"aria-label":\s*rawText/);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("renderer interpolates a column's per-cell template into DOM text (#214)", async () => {
|
|
349
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
350
|
+
const js = res.body ?? "";
|
|
351
|
+
// A column declaring `template: "{{a}}/{{b}}"` renders one cell by splicing the
|
|
352
|
+
// named row fields into a string, coerced to text and set only as DOM text
|
|
353
|
+
// (never markup). Guard the whole shape so the primitive can't silently
|
|
354
|
+
// regress: interpTemplate exists, replaces {{field}} tokens via a function
|
|
355
|
+
// replacement (so a "$"-bearing value can't be reinterpreted as a replacement
|
|
356
|
+
// pattern), coerces null/undefined to "" (unknown tokens render blank, no
|
|
357
|
+
// throw), and gridCell prefers a string col.template over the raw field value
|
|
358
|
+
// for the visible text while the badge stays keyed to the raw field.
|
|
359
|
+
assert.match(js, /function interpTemplate\(tpl, row\)/);
|
|
360
|
+
assert.match(js, /replace\(\/\\\{\\\{\(\[\^\{\}\]\+\)\\\}\\\}\/g,/);
|
|
361
|
+
assert.match(js, /const key = name\.trim\(\);/);
|
|
362
|
+
// Unknown tokens (no such own field) render blank and, crucially, an inherited
|
|
363
|
+
// Object.prototype key (toString, constructor, …) counts as unknown rather than
|
|
364
|
+
// resolving to prototype cruft — guard the own-property gate explicitly.
|
|
365
|
+
assert.match(js, /Object\.prototype\.hasOwnProperty\.call\(row, key\) \? row\[key\] : null/);
|
|
366
|
+
assert.match(js, /return v == null \? "" : String\(v\);/);
|
|
367
|
+
// gridCell keeps the raw single-field value separate (drives the badge gate)
|
|
368
|
+
// and lets a string template win for the rendered text.
|
|
369
|
+
assert.match(js, /const rawText = row\[col\.field\] == null \? "" : String\(row\[col\.field\]\);/);
|
|
370
|
+
assert.match(js, /const text = typeof col\.template === "string" \? interpTemplate\(col\.template, row\) : rawText;/);
|
|
346
371
|
});
|
|
347
372
|
|
|
348
373
|
test("renderer wires a column's processExplorer link to the console explorer", async () => {
|
|
@@ -502,6 +527,48 @@ test("the renderer honours boolean (checkbox) actionForm fields", async () => {
|
|
|
502
527
|
assert.match(js, /input\.checked = input\.defaultChecked;/);
|
|
503
528
|
});
|
|
504
529
|
|
|
530
|
+
test("the renderer enforces required actionForm fields client-side, blocking submit with an inline hint", async () => {
|
|
531
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
532
|
+
const js = res.body ?? "";
|
|
533
|
+
// A field declared `required: true` is tracked in a Set and given an inline
|
|
534
|
+
// error node; the label carries a danger-toned "*" marker before submit.
|
|
535
|
+
assert.match(js, /required\.add\(f\.key\)/);
|
|
536
|
+
assert.match(js, /reqMark = \(f\) =>/);
|
|
537
|
+
assert.match(js, /class: "pc-req"/);
|
|
538
|
+
// The input is marked aria-required, and a clear-on-edit handler wipes the hint
|
|
539
|
+
// the moment the user starts fixing the field (input for text/number, change for
|
|
540
|
+
// a checkbox).
|
|
541
|
+
assert.match(js, /input\.setAttribute\("aria-required", "true"\)/);
|
|
542
|
+
assert.match(js, /wireField\(f, input, ferr, "input"\)/);
|
|
543
|
+
assert.match(js, /wireField\(f, input, ferr, "change"\)/);
|
|
544
|
+
// Submit is GATED: an empty required text/number or an unchecked required
|
|
545
|
+
// checkbox is "missing"; the first offender is focused and the submit RETURNS
|
|
546
|
+
// early (never round-trips to the server).
|
|
547
|
+
assert.match(js, /input\.checked !== true/);
|
|
548
|
+
assert.match(js, /!Number\.isFinite\(Number\(val\)\)/);
|
|
549
|
+
assert.match(js, /if \(firstInvalid\)/);
|
|
550
|
+
assert.match(js, /Please fill in the required fields\./);
|
|
551
|
+
assert.match(js, /firstInvalid\.focus\(\)/);
|
|
552
|
+
assert.match(js, /input\.classList\.add\("pc-invalid"\)/);
|
|
553
|
+
// Editing a required field after a blocked submit clears the summary banner too,
|
|
554
|
+
// so "Please fill in the required fields." never lingers stale while correcting.
|
|
555
|
+
assert.match(js, /msg\.textContent = "";\s*\n\s*msg\.className = "pc-msg";/);
|
|
556
|
+
// A per-field custom message (`requiredMessage`) overrides the default "Required".
|
|
557
|
+
assert.match(js, /reqMsg\.get\(k\) \|\| "Required"/);
|
|
558
|
+
assert.match(js, /f\.requiredMessage/);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("the shell styles required-field markers and inline field errors", async () => {
|
|
562
|
+
const res = await dispatch("GET", "/");
|
|
563
|
+
const html = res.body ?? "";
|
|
564
|
+
// The "*" required marker and the inline per-field error both resolve through
|
|
565
|
+
// the shared danger token, and an empty error node collapses (no reserved gap).
|
|
566
|
+
assert.match(html, /\.pc-req \{ color:var\(--nano-danger\)/);
|
|
567
|
+
assert.match(html, /\.pc-field-err \{ font-size:.75rem; color:var\(--nano-danger\)/);
|
|
568
|
+
assert.match(html, /\.pc-field-err:empty \{ display:none; \}/);
|
|
569
|
+
assert.match(html, /input\.pc-invalid/);
|
|
570
|
+
});
|
|
571
|
+
|
|
505
572
|
test("renderer makes collapsible nodes persist their state across sessions", async () => {
|
|
506
573
|
// A node with `collapsible` gets a clickable header that hides/shows the card
|
|
507
574
|
// body, and the collapsed state is written to localStorage keyed by the home
|
|
@@ -533,6 +533,10 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
|
|
|
533
533
|
.pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
|
|
534
534
|
.pc-msg.err { color:var(--nano-danger); }
|
|
535
535
|
.pc-msg.ok { color:var(--nano-ok); }
|
|
536
|
+
.pc-req { color:var(--nano-danger); font-weight:600; }
|
|
537
|
+
.pc-field-err { font-size:.75rem; color:var(--nano-danger); margin:.1rem 0 0; }
|
|
538
|
+
.pc-field-err:empty { display:none; }
|
|
539
|
+
.pc-field input.pc-invalid, .pc-field-check input.pc-invalid { border-color:var(--nano-danger); outline-color:var(--nano-danger); }
|
|
536
540
|
table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; table-layout:fixed; }
|
|
537
541
|
/* table-layout:fixed divides the available width across columns and wraps text
|
|
538
542
|
within each, so a grid with several long free-text columns (e.g. the epic
|
|
@@ -834,6 +838,27 @@ function el(tag, attrs = {}, ...kids) {
|
|
|
834
838
|
function interpParam(s) {
|
|
835
839
|
return typeof s === "string" ? s.replace(/\{\{param\}\}/g, () => PARAM) : s;
|
|
836
840
|
}
|
|
841
|
+
|
|
842
|
+
// Interpolate {{field}} tokens in a column's per-cell template with values from
|
|
843
|
+
// the row, coercing each to a string (null/undefined → ""). Like interpParam the
|
|
844
|
+
// result is only ever set as DOM text (textContent via el()), so a substituted
|
|
845
|
+
// row value cannot inject markup or attributes. A function replacement inserts
|
|
846
|
+
// each value literally, so a "$"-bearing value can't be reinterpreted as a
|
|
847
|
+
// replacement pattern. Unknown tokens (no such field) render empty rather than
|
|
848
|
+
// throwing, matching interpParam. This is a dumb text formatter: no expressions,
|
|
849
|
+
// arithmetic, or conditionals — just field splicing. Only the row's own
|
|
850
|
+
// fields resolve (own-property gate, enumerable or not): an inherited
|
|
851
|
+
// Object.prototype key (toString, constructor, …) counts as unknown and
|
|
852
|
+
// renders "", so a token can't pick up prototype cruft instead of blank.
|
|
853
|
+
function interpTemplate(tpl, row) {
|
|
854
|
+
return typeof tpl === "string"
|
|
855
|
+
? tpl.replace(/\{\{([^{}]+)\}\}/g, (_m, name) => {
|
|
856
|
+
const key = name.trim();
|
|
857
|
+
const v = Object.prototype.hasOwnProperty.call(row, key) ? row[key] : null;
|
|
858
|
+
return v == null ? "" : String(v);
|
|
859
|
+
})
|
|
860
|
+
: tpl;
|
|
861
|
+
}
|
|
837
862
|
function renderText(node) {
|
|
838
863
|
const v = node.props.variant;
|
|
839
864
|
const cls = v === "heading" ? "pc-heading" : v === "sub" ? "pc-sub" : "pc-body";
|
|
@@ -998,8 +1023,27 @@ function renderButton(node) {
|
|
|
998
1023
|
// host's own explorer view in place via the nano-navigate postMessage bridge
|
|
999
1024
|
// (a modified/middle click keeps the new-tab behavior). Shared by the top-level
|
|
1000
1025
|
// grid and child grids.
|
|
1026
|
+
//
|
|
1027
|
+
// Cell TEXT (the 'text' local below): the visible string a cell renders. By
|
|
1028
|
+
// default it is the column's single 'field' value, but an optional
|
|
1029
|
+
// 'col.template' string wins: its {{field}} tokens are spliced from the row
|
|
1030
|
+
// (interpTemplate) into DOM text. A template lets one cell combine several
|
|
1031
|
+
// fields / add surrounding text (e.g. "{{current_wave}}/{{wave_count}}")
|
|
1032
|
+
// without denormalising a display column into the datasource. The template
|
|
1033
|
+
// drives the rendered text for the plain, linkField and link
|
|
1034
|
+
// (page/processExplorer) paths — a link's href still comes from its own
|
|
1035
|
+
// keyField/linkField, never the template. The 'badge' presence gate and its
|
|
1036
|
+
// tooltip stay keyed to the raw 'field' value (a badge shows a fixed glyph, not
|
|
1037
|
+
// the field text), so a template does not change badge behaviour. Sorting,
|
|
1038
|
+
// groupBy and orderBy also key off real fields, never the rendered template.
|
|
1001
1039
|
function gridCell(col, row) {
|
|
1002
|
-
|
|
1040
|
+
// Raw single-field value: drives the badge presence gate + tooltip and any
|
|
1041
|
+
// field-derived defaults. Kept separate from the (possibly templated) display
|
|
1042
|
+
// text so a template never re-lights or blanks a badge.
|
|
1043
|
+
const rawText = row[col.field] == null ? "" : String(row[col.field]);
|
|
1044
|
+
// Visible text: a col.template (interpolated from the row) wins over the
|
|
1045
|
+
// raw field value, else the field value is shown verbatim.
|
|
1046
|
+
const text = typeof col.template === "string" ? interpTemplate(col.template, row) : rawText;
|
|
1003
1047
|
// 1. badge — a compact status indicator. When the row's field value is
|
|
1004
1048
|
// non-empty (truthy after trimming) render a small circular badge (e.g. a
|
|
1005
1049
|
// red "1" dot flagging an incident) whose tooltip is the full field text;
|
|
@@ -1011,14 +1055,14 @@ function gridCell(col, row) {
|
|
|
1011
1055
|
// field text is also mirrored into aria-label so assistive tech announces
|
|
1012
1056
|
// the incident/status text (the visible "1" glyph alone is not meaningful).
|
|
1013
1057
|
if (col.badge) {
|
|
1014
|
-
if (
|
|
1058
|
+
if (rawText.trim() === "") return el("td", {});
|
|
1015
1059
|
const t = col.badge.tone;
|
|
1016
1060
|
const tone = t === "warn" || t === "ok" || t === "info" ? t : "danger";
|
|
1017
1061
|
const label = col.badge.label == null ? "1" : String(col.badge.label);
|
|
1018
1062
|
return el(
|
|
1019
1063
|
"td",
|
|
1020
1064
|
{},
|
|
1021
|
-
el("span", { class: "pc-badge pc-badge-" + tone, title:
|
|
1065
|
+
el("span", { class: "pc-badge pc-badge-" + tone, title: rawText, "aria-label": rawText }, label),
|
|
1022
1066
|
);
|
|
1023
1067
|
}
|
|
1024
1068
|
if (col.linkField) {
|
|
@@ -1083,9 +1127,38 @@ function renderActionForm(node) {
|
|
|
1083
1127
|
// pollute Object.prototype or shadow inherited props (prototype-pollution class).
|
|
1084
1128
|
const inputs = new Map();
|
|
1085
1129
|
const fieldTypes = new Map();
|
|
1130
|
+
// Client-side required-field validation state: the set of required keys, each
|
|
1131
|
+
// field's custom message, and its inline error node (populated on a blocked
|
|
1132
|
+
// submit, cleared as soon as the user edits the field).
|
|
1133
|
+
const required = new Set();
|
|
1134
|
+
const reqMsg = new Map();
|
|
1135
|
+
const errs = new Map();
|
|
1136
|
+
// A required field's label carries a danger-toned "*" so the requirement is
|
|
1137
|
+
// visible BEFORE submit; spread into el(...) — an empty array adds no child.
|
|
1138
|
+
const reqMark = (f) => (f.required === true ? [el("span", { class: "pc-req", title: "Required" }, " *")] : []);
|
|
1139
|
+
// Register a field's inline error node and, when required, mark the input
|
|
1140
|
+
// (aria-required) and wire a clear-on-edit handler so a hint disappears the
|
|
1141
|
+
// moment the user starts fixing it. Editing a required field also clears the
|
|
1142
|
+
// blocked-submit summary ("Please fill in the required fields.") so it never
|
|
1143
|
+
// lingers as a stale/misleading banner while the user is correcting input.
|
|
1144
|
+
const wireField = (f, input, ferr, evt) => {
|
|
1145
|
+
errs.set(f.key, ferr);
|
|
1146
|
+
if (f.required !== true) return;
|
|
1147
|
+
required.add(f.key);
|
|
1148
|
+
if (typeof f.requiredMessage === "string" && f.requiredMessage !== "") reqMsg.set(f.key, f.requiredMessage);
|
|
1149
|
+
input.setAttribute("aria-required", "true");
|
|
1150
|
+
input.addEventListener(evt, () => {
|
|
1151
|
+
input.classList.remove("pc-invalid");
|
|
1152
|
+
input.removeAttribute("aria-invalid");
|
|
1153
|
+
ferr.textContent = "";
|
|
1154
|
+
msg.textContent = "";
|
|
1155
|
+
msg.className = "pc-msg";
|
|
1156
|
+
});
|
|
1157
|
+
};
|
|
1086
1158
|
for (const f of p.fields || []) {
|
|
1087
1159
|
const kind = f.type === "checkbox" ? "checkbox" : (f.type === "number" ? "number" : "text");
|
|
1088
1160
|
fieldTypes.set(f.key, kind);
|
|
1161
|
+
const ferr = el("p", { class: "pc-field-err" });
|
|
1089
1162
|
if (kind === "checkbox") {
|
|
1090
1163
|
// A boolean field renders a real checkbox; its default checked state comes
|
|
1091
1164
|
// from default/checked on the field (unset -> unchecked). We record that on
|
|
@@ -1096,9 +1169,10 @@ function renderActionForm(node) {
|
|
|
1096
1169
|
input.defaultChecked = checked;
|
|
1097
1170
|
input.checked = checked;
|
|
1098
1171
|
inputs.set(f.key, input);
|
|
1172
|
+
wireField(f, input, ferr, "change");
|
|
1099
1173
|
// Input-first, wrapped in the label so the whole row is a click target.
|
|
1100
1174
|
card.append(
|
|
1101
|
-
el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key))),
|
|
1175
|
+
el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key), ...reqMark(f)), ferr),
|
|
1102
1176
|
);
|
|
1103
1177
|
continue;
|
|
1104
1178
|
}
|
|
@@ -1111,11 +1185,43 @@ function renderActionForm(node) {
|
|
|
1111
1185
|
}
|
|
1112
1186
|
const input = el("input", attrs);
|
|
1113
1187
|
inputs.set(f.key, input);
|
|
1114
|
-
|
|
1188
|
+
wireField(f, input, ferr, "input");
|
|
1189
|
+
card.append(el("div", { class: "pc-field" }, el("label", {}, f.label || f.key, ...reqMark(f)), input, ferr));
|
|
1115
1190
|
}
|
|
1116
1191
|
const msg = el("p", { class: "pc-msg" });
|
|
1117
1192
|
const btn = el("button", { class: "pc-btn" }, p.submitLabel || "Submit");
|
|
1118
1193
|
btn.addEventListener("click", async () => {
|
|
1194
|
+
// Required-field gate: block the submit and surface an inline hint on each
|
|
1195
|
+
// empty required field (a text/number left blank, a required checkbox left
|
|
1196
|
+
// unchecked), focusing the first offender — so a missing value never
|
|
1197
|
+
// round-trips to the server only to bounce back as a generic error.
|
|
1198
|
+
let firstInvalid = null;
|
|
1199
|
+
for (const [k, input] of inputs) {
|
|
1200
|
+
const ferr = errs.get(k);
|
|
1201
|
+
if (ferr) ferr.textContent = "";
|
|
1202
|
+
input.classList.remove("pc-invalid");
|
|
1203
|
+
input.removeAttribute("aria-invalid");
|
|
1204
|
+
if (!required.has(k)) continue;
|
|
1205
|
+
const type = fieldTypes.get(k);
|
|
1206
|
+
const val = String(input.value).trim();
|
|
1207
|
+
const missing = type === "checkbox"
|
|
1208
|
+
? input.checked !== true
|
|
1209
|
+
: type === "number"
|
|
1210
|
+
? (val === "" || !Number.isFinite(Number(val)))
|
|
1211
|
+
: val === "";
|
|
1212
|
+
if (missing) {
|
|
1213
|
+
if (ferr) ferr.textContent = reqMsg.get(k) || "Required";
|
|
1214
|
+
input.classList.add("pc-invalid");
|
|
1215
|
+
input.setAttribute("aria-invalid", "true");
|
|
1216
|
+
if (!firstInvalid) firstInvalid = input;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
if (firstInvalid) {
|
|
1220
|
+
msg.className = "pc-msg err";
|
|
1221
|
+
msg.textContent = "Please fill in the required fields.";
|
|
1222
|
+
firstInvalid.focus();
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1119
1225
|
const variables = Object.create(null);
|
|
1120
1226
|
for (const [k, input] of inputs) {
|
|
1121
1227
|
if (fieldTypes.get(k) === "checkbox") {
|
|
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import {
|
|
4
4
|
emitDomainModelJson,
|
|
5
5
|
resolveShapes,
|
|
6
|
+
scanModelShapes,
|
|
6
7
|
type ShapeDecl,
|
|
7
8
|
} from "./shapes.ts";
|
|
8
9
|
import type { DomainTypeRegistry, SourceSchema } from "./domain.ts";
|
|
@@ -169,3 +170,68 @@ test("resolveShapes returns an empty resolution for no shapes", () => {
|
|
|
169
170
|
assert.deepEqual(types, {});
|
|
170
171
|
assert.deepEqual(diagnostics, []);
|
|
171
172
|
});
|
|
173
|
+
|
|
174
|
+
// --- scanModelShapes: the standalone-`urban gen` port of the `nano:shape` XML scan --------------
|
|
175
|
+
|
|
176
|
+
const SHAPES_BPMN = `<?xml version="1.0"?>
|
|
177
|
+
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
|
178
|
+
xmlns:nano="http://nanobpm.io/schema/nano/1.0">
|
|
179
|
+
<bpmn:process id="p1">
|
|
180
|
+
<bpmn:extensionElements>
|
|
181
|
+
<nano:shapes>
|
|
182
|
+
<nano:shape id="ReviewIn" name="Review — input">
|
|
183
|
+
<nano:extend name="prUrl" type="string" />
|
|
184
|
+
<nano:extend name="round" type="integer" />
|
|
185
|
+
<nano:extend name="answer" type="string" optional="true" />
|
|
186
|
+
</nano:shape>
|
|
187
|
+
<nano:shape id="Composed">
|
|
188
|
+
<nano:carry ref="ReviewIn" />
|
|
189
|
+
<nano:project ref="Customer" fields="tier, region" via="Order.customerId" />
|
|
190
|
+
<nano:reference name="lines" ref="OrderLine" spread="false" list="true" />
|
|
191
|
+
</nano:shape>
|
|
192
|
+
<nano:shape name="no-id-dropped"><nano:carry ref="ReviewIn" /></nano:shape>
|
|
193
|
+
</nano:shapes>
|
|
194
|
+
</bpmn:extensionElements>
|
|
195
|
+
</bpmn:process>
|
|
196
|
+
</bpmn:definitions>`;
|
|
197
|
+
|
|
198
|
+
test("scanModelShapes lifts nano:shape declarations with ops in author order", () => {
|
|
199
|
+
const shapes = scanModelShapes(SHAPES_BPMN);
|
|
200
|
+
// The id-less shape is dropped; the two identified shapes survive, process-tagged.
|
|
201
|
+
assert.deepEqual(shapes.map((s) => s.id), ["ReviewIn", "Composed"]);
|
|
202
|
+
assert.equal(shapes[0].process, "p1");
|
|
203
|
+
assert.equal(shapes[0].name, "Review — input");
|
|
204
|
+
assert.deepEqual(shapes[0].ops, [
|
|
205
|
+
{ op: "extend", name: "prUrl", type: "string", optional: false, list: false },
|
|
206
|
+
{ op: "extend", name: "round", type: "integer", optional: false, list: false },
|
|
207
|
+
{ op: "extend", name: "answer", type: "string", optional: true, list: false },
|
|
208
|
+
]);
|
|
209
|
+
assert.deepEqual(shapes[1].ops, [
|
|
210
|
+
{ op: "carry", ref: "ReviewIn" },
|
|
211
|
+
{ op: "project", ref: "Customer", fields: ["tier", "region"], via: "Order.customerId" },
|
|
212
|
+
{ op: "reference", name: "lines", ref: "OrderLine", spread: false, list: true },
|
|
213
|
+
]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("scanModelShapes → resolveShapes round-trips an all-extend envelope into DomainTypes", () => {
|
|
217
|
+
const shapes = scanModelShapes(SHAPES_BPMN).filter((s) => s.id === "ReviewIn");
|
|
218
|
+
const { types, diagnostics } = resolveShapes(shapes, {}, []);
|
|
219
|
+
assert.deepEqual(diagnostics, []);
|
|
220
|
+
assert.deepEqual(types.ReviewIn.fields, {
|
|
221
|
+
prUrl: { type: "string" },
|
|
222
|
+
round: { type: "integer" },
|
|
223
|
+
answer: { type: "string", optional: true },
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("scanModelShapes drops an op missing a required attribute", () => {
|
|
228
|
+
const xml = `<bpmn:process id="p"><bpmn:extensionElements><nano:shapes>
|
|
229
|
+
<nano:shape id="S">
|
|
230
|
+
<nano:extend name="ok" type="string" />
|
|
231
|
+
<nano:extend name="noType" />
|
|
232
|
+
<nano:carry />
|
|
233
|
+
</nano:shape>
|
|
234
|
+
</nano:shapes></bpmn:extensionElements></bpmn:process>`;
|
|
235
|
+
const [s] = scanModelShapes(xml);
|
|
236
|
+
assert.deepEqual(s.ops, [{ op: "extend", name: "ok", type: "string", optional: false, list: false }]);
|
|
237
|
+
});
|
|
@@ -50,6 +50,99 @@ export interface ShapeDecl {
|
|
|
50
50
|
meta?: Record<string, string>;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// --- model scan (`nano:shape` XML → `ShapeDecl[]`) -------------------------------------------
|
|
54
|
+
// The regex-based BPMN scan mirrors the Rust `envelope_scan.rs` shape parser (and the sibling
|
|
55
|
+
// `meta.ts`/`worker-io.ts` scanners): the toolkit is the standalone `urban gen` path, so it must
|
|
56
|
+
// lift model-authored shapes itself rather than receive them from the host reifier.
|
|
57
|
+
|
|
58
|
+
/** Read a `name="…"` attribute off an element's opening tag (any namespace prefix on the name). */
|
|
59
|
+
function shapeAttr(tag: string, name: string): string | undefined {
|
|
60
|
+
const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
|
|
61
|
+
const v = m?.[1]?.trim();
|
|
62
|
+
return v != null && v.length > 0 ? v : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A boolean attribute is true only for the exact literal `"true"` (mirrors `bool_attr`). */
|
|
66
|
+
function shapeBool(tag: string, name: string): boolean {
|
|
67
|
+
const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
|
|
68
|
+
return m?.[1]?.trim() === "true";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Split a comma-delimited `fields="a, b"` list, trimming and dropping empties (`split_fields`). */
|
|
72
|
+
function splitFields(raw: string | undefined): string[] {
|
|
73
|
+
return (raw ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The enclosing `bpmn:process` id (provenance for the `model:<processId>` tag), best-effort. */
|
|
77
|
+
function processId(xml: string): string | undefined {
|
|
78
|
+
const m = xml.match(/<[\w.-]*:?process\b[^>]*>/);
|
|
79
|
+
return m ? shapeAttr(m[0], "id") : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// One `nano:shape` element: capture its open-tag attrs (2) and inner body (3, empty when self-closing).
|
|
83
|
+
const SHAPE_RE = /<([\w.-]*:)?shape\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1?shape\s*>)/g;
|
|
84
|
+
// One composition op child (`nano:carry|project|extend|reference`): the local name (2) + its attrs (3).
|
|
85
|
+
const SHAPE_OP_RE = /<([\w.-]*:)?(carry|project|extend|reference)\b([^>]*?)\/?>/g;
|
|
86
|
+
|
|
87
|
+
/** Parse one composition-op element into a `ShapeOp`. An op missing a required attribute is dropped
|
|
88
|
+
* (returns `undefined`) so a malformed model degrades rather than emitting a spurious binding —
|
|
89
|
+
* mirrors `parse_shape_op` in `envelope_scan.rs`. */
|
|
90
|
+
function parseShapeOp(local: string, attrs: string): ShapeOp | undefined {
|
|
91
|
+
const tag = `<x ${attrs}>`;
|
|
92
|
+
switch (local) {
|
|
93
|
+
case "carry": {
|
|
94
|
+
const ref = shapeAttr(tag, "ref");
|
|
95
|
+
return ref ? { op: "carry", ref } : undefined;
|
|
96
|
+
}
|
|
97
|
+
case "project": {
|
|
98
|
+
const ref = shapeAttr(tag, "ref");
|
|
99
|
+
if (!ref) return undefined;
|
|
100
|
+
return { op: "project", ref, fields: splitFields(shapeAttr(tag, "fields")), via: shapeAttr(tag, "via") };
|
|
101
|
+
}
|
|
102
|
+
case "extend": {
|
|
103
|
+
const name = shapeAttr(tag, "name");
|
|
104
|
+
const type = shapeAttr(tag, "type");
|
|
105
|
+
if (!name || !type) return undefined;
|
|
106
|
+
return { op: "extend", name, type, optional: shapeBool(tag, "optional"), list: shapeBool(tag, "list") };
|
|
107
|
+
}
|
|
108
|
+
case "reference": {
|
|
109
|
+
const name = shapeAttr(tag, "name");
|
|
110
|
+
const ref = shapeAttr(tag, "ref");
|
|
111
|
+
if (!name || !ref) return undefined;
|
|
112
|
+
return { op: "reference", name, ref, spread: shapeBool(tag, "spread"), list: shapeBool(tag, "list") };
|
|
113
|
+
}
|
|
114
|
+
default:
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Scan one BPMN document for its `nano:shape` declarations (ADR 0040 §9), in document order, each
|
|
120
|
+
* tagged with the enclosing process for provenance. A shape with no `id` is skipped; op children
|
|
121
|
+
* are folded in author (XML) order. This is the standalone-`urban-gen` port of the shape-parsing
|
|
122
|
+
* half of the console's `envelope_scan.rs`. */
|
|
123
|
+
export function scanModelShapes(xml: string): ShapeDecl[] {
|
|
124
|
+
const proc = processId(xml);
|
|
125
|
+
const out: ShapeDecl[] = [];
|
|
126
|
+
let sm: RegExpExecArray | null;
|
|
127
|
+
SHAPE_RE.lastIndex = 0;
|
|
128
|
+
while ((sm = SHAPE_RE.exec(xml)) !== null) {
|
|
129
|
+
const open = `<x ${sm[2]}>`;
|
|
130
|
+
const id = shapeAttr(open, "id");
|
|
131
|
+
if (!id) continue;
|
|
132
|
+
const name = shapeAttr(open, "name");
|
|
133
|
+
const body = sm[3] ?? "";
|
|
134
|
+
const ops: ShapeOp[] = [];
|
|
135
|
+
let om: RegExpExecArray | null;
|
|
136
|
+
SHAPE_OP_RE.lastIndex = 0;
|
|
137
|
+
while ((om = SHAPE_OP_RE.exec(body)) !== null) {
|
|
138
|
+
const op = parseShapeOp(om[2], om[3] ?? "");
|
|
139
|
+
if (op) ops.push(op);
|
|
140
|
+
}
|
|
141
|
+
out.push({ id, name, process: proc, ops });
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
53
146
|
/** A scan/resolve-time problem with a shape, surfaced like the `workers[]` drift warning (never a
|
|
54
147
|
* silent merge). A shape with any `error` diagnostic is omitted from the fuse; a `warning` (e.g. a
|
|
55
148
|
* deliberate field shadow) still resolves. */
|
|
@@ -2,11 +2,14 @@ import { test } from "node:test";
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import {
|
|
4
4
|
deriveWorkerBindings,
|
|
5
|
+
detectEnvelopeConflicts,
|
|
5
6
|
emitWorkerBindings,
|
|
7
|
+
formatEnvelopeConflict,
|
|
6
8
|
scanModelWorkers,
|
|
7
9
|
byModelPath,
|
|
8
10
|
type WorkerIo,
|
|
9
11
|
} from "./worker-io.ts";
|
|
12
|
+
import type { DomainFieldDef, DomainTypeRegistry } from "./domain.ts";
|
|
10
13
|
|
|
11
14
|
test("byModelPath is a spec-compliant comparator: returns 0 for equal paths", () => {
|
|
12
15
|
assert.equal(byModelPath({ path: "a" }, { path: "a" }), 0);
|
|
@@ -72,6 +75,134 @@ test("emitWorkerBindings falls back to string union + no import when nothing is
|
|
|
72
75
|
assert.match(out, /export interface WorkerInputs \{\}/);
|
|
73
76
|
});
|
|
74
77
|
|
|
78
|
+
test("emitWorkerBindings unions distinct envelopes bound to one taskType (shared handler)", () => {
|
|
79
|
+
// The same worker services two processes, each declaring its own envelope. Keyed by taskType,
|
|
80
|
+
// the member is the UNION — a shared handler reads only what every caller guarantees.
|
|
81
|
+
const out = emitWorkerBindings(
|
|
82
|
+
[
|
|
83
|
+
{ taskType: "pr.persist-escalation", inputType: "PrPersistEscalationIn" },
|
|
84
|
+
{ taskType: "pr.persist-escalation", inputType: "MergeEscalationIn" },
|
|
85
|
+
],
|
|
86
|
+
["PrPersistEscalationIn", "MergeEscalationIn"],
|
|
87
|
+
);
|
|
88
|
+
assert.match(
|
|
89
|
+
out,
|
|
90
|
+
/"pr.persist-escalation": DomainTypes\["PrPersistEscalationIn"\] \| DomainTypes\["MergeEscalationIn"\];/,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("emitWorkerBindings collapses a repeated identical envelope to a single ref (no dup keys)", () => {
|
|
95
|
+
const out = emitWorkerBindings(
|
|
96
|
+
[
|
|
97
|
+
{ taskType: "pr.persist-escalation", inputType: "EscalationIn" },
|
|
98
|
+
{ taskType: "pr.persist-escalation", inputType: "EscalationIn" },
|
|
99
|
+
{ taskType: "pr.persist-escalation", inputType: "EscalationIn" },
|
|
100
|
+
],
|
|
101
|
+
["EscalationIn"],
|
|
102
|
+
);
|
|
103
|
+
// Exactly one member, no union, and the interface still parses (no duplicate identifier).
|
|
104
|
+
assert.match(out, /export interface WorkerInputs \{\n {2}"pr.persist-escalation": DomainTypes\["EscalationIn"\];\n\}/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("detectEnvelopeConflicts stays silent for a compatible subset (nwf escalation shape)", () => {
|
|
108
|
+
const types: DomainTypeRegistry = {
|
|
109
|
+
PrPersistEscalationIn: {
|
|
110
|
+
fields: {
|
|
111
|
+
prKey: { type: "string" },
|
|
112
|
+
round: { type: "integer" },
|
|
113
|
+
recordRound: { type: "boolean", optional: true },
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
MergeEscalationIn: {
|
|
117
|
+
fields: { prKey: { type: "string" }, round: { type: "integer" } },
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
const conflicts = detectEnvelopeConflicts(
|
|
121
|
+
[
|
|
122
|
+
{ taskType: "pr.persist-escalation", inputType: "PrPersistEscalationIn" },
|
|
123
|
+
{ taskType: "pr.persist-escalation", inputType: "MergeEscalationIn" },
|
|
124
|
+
],
|
|
125
|
+
types,
|
|
126
|
+
);
|
|
127
|
+
// A field present in one and absent in the other, or optional in one, is NOT a conflict.
|
|
128
|
+
assert.deepEqual(conflicts, []);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("detectEnvelopeConflicts flags a genuine field-type clash across envelopes", () => {
|
|
132
|
+
const types: DomainTypeRegistry = {
|
|
133
|
+
A: { fields: { round: { type: "integer" } } },
|
|
134
|
+
B: { fields: { round: { type: "string" } } },
|
|
135
|
+
};
|
|
136
|
+
const conflicts = detectEnvelopeConflicts(
|
|
137
|
+
[
|
|
138
|
+
{ taskType: "pr.review", inputType: "A" },
|
|
139
|
+
{ taskType: "pr.review", inputType: "B" },
|
|
140
|
+
],
|
|
141
|
+
types,
|
|
142
|
+
);
|
|
143
|
+
assert.equal(conflicts.length, 1);
|
|
144
|
+
assert.equal(conflicts[0].taskType, "pr.review");
|
|
145
|
+
assert.equal(conflicts[0].slot, "in");
|
|
146
|
+
assert.equal(conflicts[0].field, "round");
|
|
147
|
+
assert.deepEqual(conflicts[0].types.sort(), ["integer", "string"]);
|
|
148
|
+
assert.match(formatEnvelopeConflict(conflicts[0]), /field "round" is integer vs string/);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("detectEnvelopeConflicts treats a list mismatch as a conflict", () => {
|
|
152
|
+
const types: DomainTypeRegistry = {
|
|
153
|
+
A: { fields: { tags: { type: "string" } } },
|
|
154
|
+
B: { fields: { tags: { type: "string", list: true } } },
|
|
155
|
+
};
|
|
156
|
+
const conflicts = detectEnvelopeConflicts(
|
|
157
|
+
[
|
|
158
|
+
{ taskType: "t", outputType: "A" },
|
|
159
|
+
{ taskType: "t", outputType: "B" },
|
|
160
|
+
],
|
|
161
|
+
types,
|
|
162
|
+
);
|
|
163
|
+
assert.equal(conflicts.length, 1);
|
|
164
|
+
assert.equal(conflicts[0].slot, "out");
|
|
165
|
+
assert.deepEqual(conflicts[0].types.sort(), ["string", "string[]"]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("detectEnvelopeConflicts uses own-property checks for envelope ids (no prototype-chain false positives)", () => {
|
|
169
|
+
const types: DomainTypeRegistry = {
|
|
170
|
+
Real: { fields: { prKey: { type: "string" } } },
|
|
171
|
+
};
|
|
172
|
+
// An author-named envelope id that only exists on Object.prototype ("toString", "constructor")
|
|
173
|
+
// must be treated as undeclared and skipped — not read through the prototype chain, which would
|
|
174
|
+
// yield a non-registry value and (previously) crash on `.fields`.
|
|
175
|
+
const conflicts = detectEnvelopeConflicts(
|
|
176
|
+
[
|
|
177
|
+
{ taskType: "t", inputType: "Real" },
|
|
178
|
+
{ taskType: "t", inputType: "toString" },
|
|
179
|
+
{ taskType: "t", inputType: "constructor" },
|
|
180
|
+
],
|
|
181
|
+
types,
|
|
182
|
+
);
|
|
183
|
+
assert.deepEqual(conflicts, []);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("detectEnvelopeConflicts uses own-property checks for field names (no prototype-chain false positives)", () => {
|
|
187
|
+
// Envelope A carries an own field literally named "toString"; B does not carry it at all.
|
|
188
|
+
// A prototype-chain read (`B.fields["toString"]`) would resolve to Object.prototype.toString and
|
|
189
|
+
// fabricate a spurious type conflict; an own-property check correctly sees it as a presence
|
|
190
|
+
// difference, which is not a conflict.
|
|
191
|
+
const aFields: Record<string, DomainFieldDef> = { toString: { type: "string" } };
|
|
192
|
+
const types: DomainTypeRegistry = {
|
|
193
|
+
A: { fields: aFields },
|
|
194
|
+
B: { fields: { prKey: { type: "string" } } },
|
|
195
|
+
};
|
|
196
|
+
const conflicts = detectEnvelopeConflicts(
|
|
197
|
+
[
|
|
198
|
+
{ taskType: "t", inputType: "A" },
|
|
199
|
+
{ taskType: "t", inputType: "B" },
|
|
200
|
+
],
|
|
201
|
+
types,
|
|
202
|
+
);
|
|
203
|
+
assert.deepEqual(conflicts, []);
|
|
204
|
+
});
|
|
205
|
+
|
|
75
206
|
test("deriveWorkerBindings is deterministic and emits worker-io.d.ts", () => {
|
|
76
207
|
const models = [{ path: "processes/orders.bpmn", xml: XML }];
|
|
77
208
|
const a1 = deriveWorkerBindings(models, ["Payment", "Receipt"]);
|