@nanobpm/nano-workforce 0.138.3 → 0.139.1
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/CHANGELOG.md +12 -0
- package/app/contracts.ts +16 -0
- package/app/deliveryGraph.test.ts +84 -0
- package/app/deliveryGraph.ts +79 -0
- package/app/deliveryGraphCompiler.ts +4 -2
- package/app/deliveryGraphLibrary.test.ts +134 -0
- package/app/deliveryGraphLibrary.ts +153 -0
- package/app/deliveryGraphProposals.test.ts +136 -0
- package/app/deliveryGraphProposals.ts +54 -6
- package/app/deliveryGraphShape.test.ts +66 -0
- package/app/deliveryGraphShape.ts +67 -0
- package/app/deliveryGraphTextIngress.test.ts +137 -0
- package/app/deliveryGraphTextIngress.ts +73 -3
- package/db/migrations/085_delivery_graph_library.sql +32 -0
- package/openapi.yaml +366 -0
- package/operations/deleteLibraryEntry.test.ts +88 -0
- package/operations/deleteLibraryEntry.ts +23 -0
- package/operations/dismissProposal.test.ts +105 -0
- package/operations/dismissProposal.ts +53 -0
- package/operations/getLibraryEntry.test.ts +75 -0
- package/operations/getLibraryEntry.ts +25 -0
- package/operations/importToLibrary.test.ts +195 -0
- package/operations/importToLibrary.ts +62 -0
- package/operations/listLibrary.test.ts +79 -0
- package/operations/listLibrary.ts +24 -0
- package/operations/saveToLibrary.test.ts +225 -0
- package/operations/saveToLibrary.ts +89 -0
- package/package.json +1 -1
- package/pages/delivery-graphs/delivery-graphs.css +33 -0
- package/pages/delivery-graphs/embed.html +1 -0
- package/pages/delivery-graphs/library-embed.html +31 -0
- package/pages/delivery-graphs/library-standalone.html +38 -0
- package/pages/delivery-graphs/library.mount.js +374 -0
- package/pages/delivery-graphs/mount.js +144 -6
- package/pages/delivery-graphs/staged.mount.js +124 -9
- package/pages/delivery-graphs/standalone.html +2 -1
- package/pages/delivery-graphs.page.json +24 -1
- package/scripts/pages-contract.test.ts +50 -0
- package/test/delivery-graphs-embed.test.ts +39 -16
- package/test/delivery-graphs-import.test.ts +110 -0
- package/test/delivery-graphs-library-embed.test.ts +156 -0
- package/test/delivery-graphs-library-export.test.ts +62 -0
- package/test/delivery-graphs-staged-embed.test.ts +69 -17
|
@@ -17,14 +17,31 @@
|
|
|
17
17
|
// demand×supply board (pages/board/mount.js): the SAME module mounts embedded in the console (App View)
|
|
18
18
|
// and standalone — only the host element and injected endpoint config differ.
|
|
19
19
|
|
|
20
|
-
// The
|
|
21
|
-
//
|
|
22
|
-
|
|
20
|
+
// The door defaults are anchored to THIS MODULE's url (import.meta.url), NOT the document base. The
|
|
21
|
+
// staged App-View shell (staged-embed.html / staged-standalone.html) — and therefore this mount.js —
|
|
22
|
+
// is served ONE DIRECTORY DEEP at `<appMount>/delivery-graphs/`, while the API is a sibling of that
|
|
23
|
+
// dir at `<appMount>/app/api/…`. A document-base-relative default (`"app/api/delivery-graph/staged"`)
|
|
24
|
+
// resolves against the `…/delivery-graphs/` shell base to `…/delivery-graphs/app/api/delivery-graph/
|
|
25
|
+
// staged` → 404 on EVERY surface (standalone, local urban-SPA App-View, and the Studio console
|
|
26
|
+
// App-View, which never injects window.__NANO_APP_VIEW__ so this default is what runs) — the
|
|
27
|
+
// "Could not load staged proposals." bug. A leading-slash absolute is worse still: through Studio it
|
|
28
|
+
// resolves against the console ORIGIN, not the app-view base (#279). `../app/api/…` off
|
|
29
|
+
// import.meta.url steps up out of `/delivery-graphs/` and lands on `<appMount>/app/api/…` on all
|
|
30
|
+
// three surfaces regardless of the document base — the same fix the cockpit shipped for #467.
|
|
31
|
+
// The read behind the list: every live staged proposal, newest first.
|
|
32
|
+
const DEFAULT_STAGED_URL = new URL("../app/api/delivery-graph/staged", import.meta.url).href;
|
|
23
33
|
// The operator dispatch door: POST { digest } → launches the staged graph engine-natively (#460).
|
|
24
|
-
const DEFAULT_DISPATCH_URL = "app/api/actions/delivery-graph/dispatch";
|
|
34
|
+
const DEFAULT_DISPATCH_URL = new URL("../app/api/actions/delivery-graph/dispatch", import.meta.url).href;
|
|
35
|
+
// The operator dismiss door: POST { digest } → discards a staged proposal as noise, flipping it to the
|
|
36
|
+
// terminal `dismissed` status so it drops off the staged list (#520). Launches nothing.
|
|
37
|
+
const DEFAULT_DISMISS_URL = new URL("../app/api/actions/delivery-graph/dismiss", import.meta.url).href;
|
|
25
38
|
// The read-only DI preview door: recompiles a staged proposal's BPMN (with diagram interchange) so its
|
|
26
39
|
// generated diagram can be rendered in the host explorer BEFORE dispatch. No deploy, no dispatch.
|
|
27
|
-
const DEFAULT_PROPOSAL_BPMN_URL = "app/api/actions/delivery-graph/proposal-bpmn";
|
|
40
|
+
const DEFAULT_PROPOSAL_BPMN_URL = new URL("../app/api/actions/delivery-graph/proposal-bpmn", import.meta.url).href;
|
|
41
|
+
// The save-to-library door: POST { name, digest } → copies this staged proposal's already-stored graph
|
|
42
|
+
// into the reusable library (issue #523, save-from-digest → source `from-staged`). Persists a library
|
|
43
|
+
// entry; it never dispatches or re-stages, so the #460 operator boundary holds.
|
|
44
|
+
const DEFAULT_SAVE_LIBRARY_URL = new URL("../app/api/actions/delivery-graph/library/save", import.meta.url).href;
|
|
28
45
|
|
|
29
46
|
// How often the list re-polls the read door so a freshly-staged (or just-dispatched) proposal appears
|
|
30
47
|
// (or drops off) without a manual refresh — mirrors the 5s cadence the old declarative grid used.
|
|
@@ -42,6 +59,13 @@ const DISPATCH_CONFIRM =
|
|
|
42
59
|
"node (it merges PRs / publishes packages) will run. Clicking Dispatch IS the approval, " +
|
|
43
60
|
"content-addressed to exactly the graph you previewed.";
|
|
44
61
|
|
|
62
|
+
// The confirm shown before a dismiss — dismissing is a terminal discard: the proposal drops off the
|
|
63
|
+
// staged list for good (it can be re-staged only by recompiling). It launches nothing, so this is a
|
|
64
|
+
// lighter acknowledgement than Dispatch, but still a one-way action the operator confirms.
|
|
65
|
+
const DISMISS_CONFIRM =
|
|
66
|
+
"Dismiss this staged delivery graph? It is discarded as noise and drops off the staged list — this " +
|
|
67
|
+
"launches nothing, but to bring it back you must recompile/re-stage it.";
|
|
68
|
+
|
|
45
69
|
/** Escape untrusted strings before they touch innerHTML. */
|
|
46
70
|
function esc(value) {
|
|
47
71
|
return String(value ?? "").replace(
|
|
@@ -76,6 +100,8 @@ function renderProposal(p) {
|
|
|
76
100
|
<div class="actions">
|
|
77
101
|
<button class="btn btn-ghost" type="button" data-preview-di="${esc(p.digest)}">Preview generated DI</button>
|
|
78
102
|
<button class="btn btn-primary" type="button" data-dispatch="${esc(p.digest)}">Dispatch</button>
|
|
103
|
+
<button class="btn btn-ghost" type="button" data-save-library="${esc(p.digest)}" data-title="${esc(p.title ?? "")}">Save to library</button>
|
|
104
|
+
<button class="btn btn-ghost" type="button" data-dismiss="${esc(p.digest)}">Dismiss</button>
|
|
79
105
|
</div>
|
|
80
106
|
</section>`;
|
|
81
107
|
}
|
|
@@ -95,10 +121,24 @@ function renderList(proposals) {
|
|
|
95
121
|
return header + proposals.map(renderProposal).join("");
|
|
96
122
|
}
|
|
97
123
|
|
|
124
|
+
// Only attach the guard secret when the resolved door URL is SAME-ORIGIN. The staged/dispatch/dismiss/
|
|
125
|
+
// proposal-bpmn/save-library door URLs can be overridden (e.g. via the standalone `?staged=` /
|
|
126
|
+
// `?dispatch=` / `?proposal-bpmn=` query params) to a full `https://…` URL on a foreign origin; sending
|
|
127
|
+
// `x-hook-secret` there would exfiltrate the shared guard secret to an arbitrary host. A cross-origin
|
|
128
|
+
// (or unparseable, or non-browser) target therefore gets no secret.
|
|
129
|
+
function isSameOrigin(url) {
|
|
130
|
+
try {
|
|
131
|
+
if (typeof window === "undefined" || !window.location) return false;
|
|
132
|
+
return new URL(url, window.location.href).origin === window.location.origin;
|
|
133
|
+
} catch (_e) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
98
138
|
/**
|
|
99
139
|
* Mount the staged-proposals list into `host`.
|
|
100
140
|
* @param {Element|null} host — the element to render into (or null → look up #delivery-graphs-staged-root).
|
|
101
|
-
* @param {{stagedUrl?:string, dispatchUrl?:string, proposalBpmnUrl?:string, hookSecret?:string, refreshMs?:number}} [config]
|
|
141
|
+
* @param {{stagedUrl?:string, dispatchUrl?:string, dismissUrl?:string, proposalBpmnUrl?:string, saveLibraryUrl?:string, hookSecret?:string, refreshMs?:number}} [config]
|
|
102
142
|
*/
|
|
103
143
|
export function mountStagedProposals(host, config = {}) {
|
|
104
144
|
const isElement = host != null && host.nodeType === 1 && typeof host.innerHTML === "string";
|
|
@@ -107,11 +147,13 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
107
147
|
|
|
108
148
|
const stagedUrl = config.stagedUrl ?? DEFAULT_STAGED_URL;
|
|
109
149
|
const dispatchUrl = config.dispatchUrl ?? DEFAULT_DISPATCH_URL;
|
|
150
|
+
const dismissUrl = config.dismissUrl ?? DEFAULT_DISMISS_URL;
|
|
110
151
|
const proposalBpmnUrl = config.proposalBpmnUrl ?? DEFAULT_PROPOSAL_BPMN_URL;
|
|
152
|
+
const saveLibraryUrl = config.saveLibraryUrl ?? DEFAULT_SAVE_LIBRARY_URL;
|
|
111
153
|
const refreshMs = typeof config.refreshMs === "number" && config.refreshMs > 0 ? config.refreshMs : DEFAULT_REFRESH_MS;
|
|
112
|
-
const headers = () => ({
|
|
154
|
+
const headers = (url) => ({
|
|
113
155
|
"content-type": "application/json",
|
|
114
|
-
...(config.hookSecret ? { "x-hook-secret": config.hookSecret } : {}),
|
|
156
|
+
...(config.hookSecret && isSameOrigin(url) ? { "x-hook-secret": config.hookSecret } : {}),
|
|
115
157
|
});
|
|
116
158
|
|
|
117
159
|
root.innerHTML = `<div class="dg">
|
|
@@ -149,7 +191,7 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
149
191
|
const controller = new AbortController();
|
|
150
192
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
151
193
|
try {
|
|
152
|
-
const res = await fetch(url, { ...init, headers: headers(), signal: controller.signal });
|
|
194
|
+
const res = await fetch(url, { ...init, headers: headers(url), signal: controller.signal });
|
|
153
195
|
let body = {};
|
|
154
196
|
try {
|
|
155
197
|
body = await res.json();
|
|
@@ -254,6 +296,67 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
254
296
|
}
|
|
255
297
|
}
|
|
256
298
|
|
|
299
|
+
// "Dismiss": the operator's discard (#520). Confirm (dismiss is a one-way drop off the staged list),
|
|
300
|
+
// then POST the digest to the dismiss door; on success the proposal flips to `dismissed` and drops off
|
|
301
|
+
// the list on the next poll — refresh immediately so the operator sees it leave. Launches nothing.
|
|
302
|
+
async function doDismiss(digest) {
|
|
303
|
+
const staged = typeof digest === "string" ? digest.trim() : "";
|
|
304
|
+
if (staged === "") return;
|
|
305
|
+
if (typeof window !== "undefined" && typeof window.confirm === "function" && !window.confirm(DISMISS_CONFIRM)) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
busy(true);
|
|
309
|
+
setStatus("Dismissing…");
|
|
310
|
+
try {
|
|
311
|
+
const { status, body } = await post(dismissUrl, { digest: staged });
|
|
312
|
+
if ((status === 200 || status === 202) && body.ok) {
|
|
313
|
+
setStatus("\u2713 Dismissed — the proposal is off the staged list.", "ok");
|
|
314
|
+
await refresh();
|
|
315
|
+
} else {
|
|
316
|
+
setStatus(body && body.error ? body.error : "Dismiss failed.", "err");
|
|
317
|
+
}
|
|
318
|
+
} catch (err) {
|
|
319
|
+
setStatus(err && err.message ? err.message : "Dismiss request failed.", "err");
|
|
320
|
+
} finally {
|
|
321
|
+
busy(false);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// "Save to library": copy this staged proposal's already-stored graph into the reusable library
|
|
326
|
+
// (issue #523, save-from-digest → source `from-staged`). Prompt the operator for the entry name
|
|
327
|
+
// (defaulting to the proposal title — its slug/short-hash derive the library id, so re-saving the
|
|
328
|
+
// same name upserts), then POST { name, digest } to the save door. This persists a library entry
|
|
329
|
+
// only — it never dispatches or re-stages, so the operator boundary the staged view enforces (#460)
|
|
330
|
+
// is untouched.
|
|
331
|
+
async function doSaveToLibrary(digest, defaultName) {
|
|
332
|
+
const staged = typeof digest === "string" ? digest.trim() : "";
|
|
333
|
+
if (staged === "") return;
|
|
334
|
+
let name = defaultName ? String(defaultName) : "";
|
|
335
|
+
if (typeof window !== "undefined" && typeof window.prompt === "function") {
|
|
336
|
+
const entered = window.prompt("Save to library as (name):", name);
|
|
337
|
+
if (entered === null) return; // operator cancelled
|
|
338
|
+
name = entered;
|
|
339
|
+
}
|
|
340
|
+
if (name.trim() === "") {
|
|
341
|
+
setStatus("A library entry needs a non-blank name.", "err");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
busy(true);
|
|
345
|
+
setStatus("Saving to library…");
|
|
346
|
+
try {
|
|
347
|
+
const { status, body } = await post(saveLibraryUrl, { name: name.trim(), digest: staged });
|
|
348
|
+
if (status === 200 && body.ok) {
|
|
349
|
+
setStatus("\u2713 Saved to the library \u2014 reuse it from the Library view.", "ok");
|
|
350
|
+
} else {
|
|
351
|
+
setStatus(body && body.error ? body.error : "Save to library failed.", "err");
|
|
352
|
+
}
|
|
353
|
+
} catch (err) {
|
|
354
|
+
setStatus(err && err.message ? err.message : "Save-to-library request failed.", "err");
|
|
355
|
+
} finally {
|
|
356
|
+
busy(false);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
257
360
|
listEl.addEventListener("click", (ev) => {
|
|
258
361
|
const previewBtn = ev.target && ev.target.closest ? ev.target.closest("[data-preview-di]") : null;
|
|
259
362
|
if (previewBtn) {
|
|
@@ -265,6 +368,18 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
265
368
|
if (dispatchBtn) {
|
|
266
369
|
ev.preventDefault();
|
|
267
370
|
doDispatch(dispatchBtn.getAttribute("data-dispatch"));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const saveLibraryBtn = ev.target && ev.target.closest ? ev.target.closest("[data-save-library]") : null;
|
|
374
|
+
if (saveLibraryBtn) {
|
|
375
|
+
ev.preventDefault();
|
|
376
|
+
doSaveToLibrary(saveLibraryBtn.getAttribute("data-save-library"), saveLibraryBtn.getAttribute("data-title"));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const dismissBtn = ev.target && ev.target.closest ? ev.target.closest("[data-dismiss]") : null;
|
|
380
|
+
if (dismissBtn) {
|
|
381
|
+
ev.preventDefault();
|
|
382
|
+
doDismiss(dismissBtn.getAttribute("data-dismiss"));
|
|
268
383
|
}
|
|
269
384
|
});
|
|
270
385
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<!--
|
|
14
14
|
Standalone shell (phone / direct link). Loads the SAME ./mount.js the console App-View embed uses,
|
|
15
15
|
so the standalone and embedded views render identically. Endpoints default to the current origin;
|
|
16
|
-
override the preview/
|
|
16
|
+
override the preview/stage/import endpoints via ?preview= / ?stage= / ?import=. For a secured deployment, pass
|
|
17
17
|
the guard secret via the URL fragment #secret= (sent as x-hook-secret) — NOT the query string, so
|
|
18
18
|
it never leaks via server access logs, browser history, or the Referer header. The fragment is
|
|
19
19
|
stripped from the address bar immediately after it is read.
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
mountDeliveryGraphs(document.getElementById("delivery-graphs-root"), {
|
|
32
32
|
previewUrl: params.get("preview") ?? undefined,
|
|
33
33
|
stageUrl: params.get("stage") ?? undefined,
|
|
34
|
+
importUrl: params.get("import") ?? undefined,
|
|
34
35
|
hookSecret,
|
|
35
36
|
});
|
|
36
37
|
</script>
|
|
@@ -94,6 +94,16 @@
|
|
|
94
94
|
"fill": true
|
|
95
95
|
}
|
|
96
96
|
},
|
|
97
|
+
{
|
|
98
|
+
"type": "appView",
|
|
99
|
+
"id": "delivery-graphs-library",
|
|
100
|
+
"props": {
|
|
101
|
+
"title": "Library",
|
|
102
|
+
"embed": "./delivery-graphs/library-embed.html",
|
|
103
|
+
"standalone": "./delivery-graphs/library-standalone.html",
|
|
104
|
+
"fill": true
|
|
105
|
+
}
|
|
106
|
+
},
|
|
97
107
|
{
|
|
98
108
|
"type": "dataGrid",
|
|
99
109
|
"id": "delivery-graphs-inflight",
|
|
@@ -120,10 +130,12 @@
|
|
|
120
130
|
"columns": [
|
|
121
131
|
{ "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "28%", "link": { "kind": "page", "page": "delivery-graph-detail", "keyField": "run_key" } },
|
|
122
132
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
133
|
+
{ "field": "process_key", "header": "Instance", "width": "9rem", "truncate": true, "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
123
134
|
{ "field": "phase", "header": "Phase", "truncate": true, "width": "26%" },
|
|
124
135
|
{ "field": "node_count", "header": "Nodes" },
|
|
125
136
|
{ "field": "human_node_count", "header": "Human" },
|
|
126
137
|
{ "field": "side_effect_count", "header": "Side effects" },
|
|
138
|
+
{ "field": "created_at", "header": "Dispatched", "width": "9rem", "format": "datetime" },
|
|
127
139
|
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
128
140
|
],
|
|
129
141
|
"detail": {
|
|
@@ -132,7 +144,18 @@
|
|
|
132
144
|
{ "field": "digest", "label": "Digest" },
|
|
133
145
|
{ "field": "phase_node_id", "label": "Parked node" }
|
|
134
146
|
]
|
|
135
|
-
}
|
|
147
|
+
},
|
|
148
|
+
"rowActions": [
|
|
149
|
+
{
|
|
150
|
+
"label": "Save to library",
|
|
151
|
+
"confirm": "Save this dispatched delivery graph to the reusable library? Its stored graph is copied into the Library (source: from-dispatched) so it can be reused later.",
|
|
152
|
+
"showWhenField": "title",
|
|
153
|
+
"action": {
|
|
154
|
+
"path": "/app/api/actions/delivery-graph/library/save",
|
|
155
|
+
"body": { "name": "{{row.title}}", "digest": "{{row.digest}}" }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
]
|
|
136
159
|
}
|
|
137
160
|
}
|
|
138
161
|
]
|
|
@@ -407,3 +407,53 @@ test("issue #386: the human-facing Delivery Graphs surface is wired (nav tab, pa
|
|
|
407
407
|
"overview delivery-graphs section must link its item to delivery-graph-detail by run_key",
|
|
408
408
|
);
|
|
409
409
|
});
|
|
410
|
+
|
|
411
|
+
test("issue #521: the Delivery Graphs History tab surfaces dispatch time + the instance key", async () => {
|
|
412
|
+
// The in-flight grid's History tab (`delivery-graphs-inflight`) is where a completed/failed run is
|
|
413
|
+
// reviewed after the fact. It must surface WHEN the run was dispatched (`created_at`, stamped at
|
|
414
|
+
// dispatch) and its engine instance key (`process_key`) as a first-class, Explorer-linked cell —
|
|
415
|
+
// not just the `updated_at` last-touch. Grid columns are shared across the In-flight/History/All
|
|
416
|
+
// tabs (the renderer has no per-tab column override — tabs carry only `label`+`filter`), so pinning
|
|
417
|
+
// the columns on the grid that owns the History tab is what surfaces them on History.
|
|
418
|
+
const page = JSON.parse(readFileSync(`${ROOT}pages/delivery-graphs.page.json`, "utf8"));
|
|
419
|
+
const grid = (page.nodes ?? []).find(
|
|
420
|
+
(n: Json) => n.type === "dataGrid" && n.id === "delivery-graphs-inflight",
|
|
421
|
+
);
|
|
422
|
+
assert(grid, "delivery-graphs page must have the `delivery-graphs-inflight` grid");
|
|
423
|
+
|
|
424
|
+
// The History tab must exist (this is the tab whose columns we are pinning).
|
|
425
|
+
const history = (grid.props?.tabs ?? []).find((t: Json) => t.label === "History");
|
|
426
|
+
assert(history, "the delivery-graphs-inflight grid must have a History tab");
|
|
427
|
+
|
|
428
|
+
const columns: Json[] = grid.props?.columns ?? [];
|
|
429
|
+
|
|
430
|
+
// Dispatched: the dispatch time, formatted as a datetime, distinct from the `updated_at` "Updated".
|
|
431
|
+
const dispatched = columns.find((c: Json) => c.field === "created_at");
|
|
432
|
+
assert(dispatched, "History tab must expose a `created_at` column (dispatch time)");
|
|
433
|
+
assert(
|
|
434
|
+
dispatched.header === "Dispatched",
|
|
435
|
+
"the `created_at` column must be headed \"Dispatched\"",
|
|
436
|
+
);
|
|
437
|
+
assert(
|
|
438
|
+
dispatched.format === "datetime",
|
|
439
|
+
"the `created_at` (Dispatched) column must be formatted as a datetime",
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
// Instance: an explicit cell carrying `process_key`, deep-linked to the Explorer via the same
|
|
443
|
+
// `processExplorer` link kind used by the Status column, keyed on `process_key`.
|
|
444
|
+
const instance = columns.find(
|
|
445
|
+
(c: Json) => c.field === "process_key" && c.link?.kind === "processExplorer",
|
|
446
|
+
);
|
|
447
|
+
assert(
|
|
448
|
+
instance,
|
|
449
|
+
"History tab must expose an explicit Instance cell on `process_key` with a processExplorer link",
|
|
450
|
+
);
|
|
451
|
+
assert(
|
|
452
|
+
instance.header === "Instance",
|
|
453
|
+
"the `process_key` cell must be headed \"Instance\"",
|
|
454
|
+
);
|
|
455
|
+
assert(
|
|
456
|
+
instance.link?.keyField === "process_key",
|
|
457
|
+
"the Instance cell's processExplorer link must key on `process_key`",
|
|
458
|
+
);
|
|
459
|
+
});
|
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
// Stage are SEPARATE operator actions (#516): Preview compiles without persisting; Stage persists a
|
|
7
7
|
// proposal. Dispatch is deliberately NOT in this view (issue #460): it is an OPERATOR row-action on the
|
|
8
8
|
// Staged proposals grid on the same page. This test pins the wiring so it can't silently regress: the
|
|
9
|
-
// sidecars exist, mount.js hits the preview + stage doors with
|
|
10
|
-
// App-View resolution class —
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// sidecars exist, mount.js hits the preview + stage doors with MODULE-ANCHORED defaults (`new
|
|
10
|
+
// URL("../app/api/…", import.meta.url)`, the #467 App-View resolution class — this module is served one
|
|
11
|
+
// dir deep under `/delivery-graphs/`, so a document-base-relative or leading-slash default 404s; see
|
|
12
|
+
// test/delivery-graphs-staged-embed.test.ts and #536), it renders each preview facet, its compose panel
|
|
13
|
+
// is collapsible, and it exposes NO dispatch/approval affordance (the self-approval hole #460 closes).
|
|
13
14
|
import { test } from "node:test";
|
|
14
|
-
import { assert } from "#test-assert";
|
|
15
|
+
import { assert, assertEquals } from "#test-assert";
|
|
15
16
|
import { readFileSync } from "node:fs";
|
|
16
17
|
import { parseAndCompileText } from "../app/deliveryGraphTextIngress.ts";
|
|
17
18
|
import { EXAMPLE_GRAPH } from "../pages/delivery-graphs/mount.js";
|
|
@@ -24,15 +25,41 @@ const STANDALONE_HTML = readFileSync(`${DIR}/standalone.html`, "utf8");
|
|
|
24
25
|
const CSS = readFileSync(`${DIR}/delivery-graphs.css`, "utf8");
|
|
25
26
|
const PAGE_JSON = readFileSync(`${ROOT}pages/delivery-graphs.page.json`, "utf8");
|
|
26
27
|
|
|
27
|
-
//
|
|
28
|
-
|
|
28
|
+
// The REAL served location of mount.js on each surface: one directory deep under `/delivery-graphs/`.
|
|
29
|
+
const STANDALONE_MOUNT = "http://127.0.0.1:3000/delivery-graphs/mount.js";
|
|
30
|
+
const STUDIO_MOUNT = "http://studio-host:8080/console/app-view/Workforce/delivery-graphs/mount.js";
|
|
31
|
+
|
|
32
|
+
// Pull the module-anchored default spec out of `const <name> = config.<field> ?? <CONST>;` where the
|
|
33
|
+
// CONST is declared `const <CONST> = new URL("<spec>", import.meta.url).href;`.
|
|
34
|
+
function defaultSpec(name: string): string {
|
|
29
35
|
const m = MOUNT_JS.match(new RegExp(`${name}\\s*=\\s*config\\.\\w+\\s*\\?\\?\\s*(\\w+);`));
|
|
30
36
|
assert(m, `mount.js must default ${name} from config with a fallback constant`);
|
|
31
|
-
const constM = MOUNT_JS.match(
|
|
32
|
-
|
|
37
|
+
const constM = MOUNT_JS.match(
|
|
38
|
+
new RegExp(`const ${m![1]}\\s*=\\s*new URL\\(\\s*"([^"]*)"\\s*,\\s*import\\.meta\\.url\\s*\\)\\s*\\.href`),
|
|
39
|
+
);
|
|
40
|
+
assert(
|
|
41
|
+
constM,
|
|
42
|
+
`mount.js must default ${m![1]} to new URL("<spec>", import.meta.url) so the door is anchored to the ` +
|
|
43
|
+
`module's own served location, not the document base (#467/#536)`,
|
|
44
|
+
);
|
|
33
45
|
return constM![1];
|
|
34
46
|
}
|
|
35
47
|
|
|
48
|
+
// A module-anchored door default must (a) not be absolute (#279), (b) step up out of /delivery-graphs/
|
|
49
|
+
// (#467), and (c) resolve onto <appMount>/app/api/… both standalone AND inside the Studio iframe.
|
|
50
|
+
function assertModuleAnchored(name: string, endpoint: string): void {
|
|
51
|
+
const spec = defaultSpec(name);
|
|
52
|
+
assert(!spec.startsWith("/"), `default ${name} spec "${spec}" must not be absolute (resolves against console origin, #279)`);
|
|
53
|
+
assert(spec.startsWith("../"), `default ${name} spec "${spec}" must step up out of /delivery-graphs/ (#467)`);
|
|
54
|
+
assert(spec.endsWith(endpoint), `default ${name} spec "${spec}" must hit the ${endpoint} door`);
|
|
55
|
+
assertEquals(new URL(spec, STANDALONE_MOUNT).href, `http://127.0.0.1:3000/${endpoint}`, `default ${name} must resolve to the app root (#467)`);
|
|
56
|
+
assertEquals(
|
|
57
|
+
new URL(spec, STUDIO_MOUNT).href,
|
|
58
|
+
`http://studio-host:8080/console/app-view/Workforce/${endpoint}`,
|
|
59
|
+
`default ${name} must resolve under the app-view base, not the console origin (#279) nor the /delivery-graphs/ base (#467/#536)`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
36
63
|
test("#441: the delivery-graphs App View mounts the same module standalone and embedded", () => {
|
|
37
64
|
assert(/mountDeliveryGraphs/.test(MOUNT_JS), "mount.js must export mountDeliveryGraphs");
|
|
38
65
|
for (const [file, html] of [["embed.html", EMBED_HTML], ["standalone.html", STANDALONE_HTML]] as const) {
|
|
@@ -41,13 +68,9 @@ test("#441: the delivery-graphs App View mounts the same module standalone and e
|
|
|
41
68
|
}
|
|
42
69
|
});
|
|
43
70
|
|
|
44
|
-
test("#516: mount.js wires SEPARATE preview and stage doors
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
assert(!previewUrl.startsWith("/"), `default previewUrl "${previewUrl}" must be base-relative (App-View #279 resolution class)`);
|
|
48
|
-
const stageUrl = defaultUrl("stageUrl");
|
|
49
|
-
assert(stageUrl.endsWith("actions/delivery-graph/stage"), `stageUrl default "${stageUrl}" must hit the stageDeliveryGraph door`);
|
|
50
|
-
assert(!stageUrl.startsWith("/"), `default stageUrl "${stageUrl}" must be base-relative (App-View #279 resolution class)`);
|
|
71
|
+
test("#516/#467: mount.js wires SEPARATE preview and stage doors, module-anchored to the app root", () => {
|
|
72
|
+
assertModuleAnchored("previewUrl", "app/api/actions/delivery-graph/preview");
|
|
73
|
+
assertModuleAnchored("stageUrl", "app/api/actions/delivery-graph/stage");
|
|
51
74
|
// Preview and Stage are distinct buttons wired to distinct actions.
|
|
52
75
|
assert(/id="dg-preview"/.test(MOUNT_JS) && /id="dg-stage"/.test(MOUNT_JS), "mount.js must render distinct Preview and Stage buttons");
|
|
53
76
|
assert(/submit\(previewUrl,\s*false\)/.test(MOUNT_JS), "the Preview button must submit to the preview door WITHOUT staging");
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Contract guard for the filesystem IMPORT wiring on the Delivery Graphs compose App-View (issue #524,
|
|
2
|
+
// epic #519 S5). The Import control is ADDED into the PRE-EXISTING compose mount (pages/delivery-graphs/
|
|
3
|
+
// mount.js — the one #523 reshaped), alongside #523's inbound reuse-fill seam. It must: render an
|
|
4
|
+
// `<input type=file accept=.json>`, read the picked file's text client-side, POST it to the
|
|
5
|
+
// importToLibrary door (module-anchored relative spec, App-View #279 + #467/#536 resolution class), route a successful import back
|
|
6
|
+
// through #523's SINGLE `fillComposer()` seam (no second inbound fill path), and render path-qualified
|
|
7
|
+
// compile errors inline on a 400. This test pins that wiring so it can't silently regress.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { assert } from "#test-assert";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
|
|
12
|
+
const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
|
|
13
|
+
const DIR = `${ROOT}pages/delivery-graphs`;
|
|
14
|
+
const MOUNT_JS = readFileSync(`${DIR}/mount.js`, "utf8");
|
|
15
|
+
const CSS = readFileSync(`${DIR}/delivery-graphs.css`, "utf8");
|
|
16
|
+
const EMBED_HTML = readFileSync(`${DIR}/embed.html`, "utf8");
|
|
17
|
+
const STANDALONE_HTML = readFileSync(`${DIR}/standalone.html`, "utf8");
|
|
18
|
+
|
|
19
|
+
// Pull the module-anchored default URL *spec* (the "<spec>" literal inside
|
|
20
|
+
// `new URL("<spec>", import.meta.url)`, e.g. "../app/api/…") out of
|
|
21
|
+
// `const <name> = config.<name> ?? <CONST>;` where the CONST is declared
|
|
22
|
+
// `const <CONST> = new URL("<spec>", import.meta.url).href;`. This is the unresolved
|
|
23
|
+
// relative spec, not a resolved URL string — hence `Spec`, not `Url`.
|
|
24
|
+
function defaultSpec(name: string): string {
|
|
25
|
+
const m = MOUNT_JS.match(new RegExp(`${name}\\s*=\\s*config\\.\\w+\\s*\\?\\?\\s*(\\w+);`));
|
|
26
|
+
assert(m, `mount.js must default ${name} from config with a fallback constant`);
|
|
27
|
+
const constM = MOUNT_JS.match(
|
|
28
|
+
new RegExp(`const ${m![1]}\\s*=\\s*new URL\\(\\s*"([^"]*)"\\s*,\\s*import\\.meta\\.url\\s*\\)\\s*\\.href`),
|
|
29
|
+
);
|
|
30
|
+
assert(
|
|
31
|
+
constM,
|
|
32
|
+
`mount.js must default ${m![1]} to new URL("<spec>", import.meta.url) so the door is anchored ` +
|
|
33
|
+
`to the module's own served location, not the document base (#467/#536)`,
|
|
34
|
+
);
|
|
35
|
+
return constM![1];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test("#524: the compose mount renders a file-input Import control accepting .json", () => {
|
|
39
|
+
assert(/id="dg-import"/.test(MOUNT_JS), "mount.js must render an Import file input with id=dg-import");
|
|
40
|
+
assert(/type="file"/.test(MOUNT_JS), "the Import control must be an <input type=file>");
|
|
41
|
+
assert(/accept="[^"]*\.json[^"]*"/.test(MOUNT_JS), "the Import file input must accept .json files");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("#524: Import wires the importToLibrary door (module-anchored relative), reading the file text client-side", () => {
|
|
45
|
+
const importSpec = defaultSpec("importUrl");
|
|
46
|
+
assert(
|
|
47
|
+
importSpec.endsWith("actions/delivery-graph/library/import"),
|
|
48
|
+
`importUrl default spec "${importSpec}" must hit the importToLibrary door`,
|
|
49
|
+
);
|
|
50
|
+
assert(!importSpec.startsWith("/"), `default importUrl spec "${importSpec}" must be relative, not absolute (App-View #279 + #467/#536 resolution class)`);
|
|
51
|
+
// Pin the MODULE-anchored invariant, not merely "not absolute": the spec must step UP out of the
|
|
52
|
+
// /delivery-graphs/ shell base with "../" (resolved against import.meta.url), so a regression back to a
|
|
53
|
+
// document-base-relative "app/api/…" — which 404s under the shell base (#467/#536) — fails the guard.
|
|
54
|
+
assert(
|
|
55
|
+
importSpec.startsWith("../"),
|
|
56
|
+
`default importUrl spec "${importSpec}" must be module-anchored with a "../" prefix (steps out of the ` +
|
|
57
|
+
`/delivery-graphs/ shell base off import.meta.url), not document-base-relative (#467/#536)`,
|
|
58
|
+
);
|
|
59
|
+
// The file's text is read CLIENT-SIDE and POSTed as graphJson to the import door.
|
|
60
|
+
assert(/\.text\(\)/.test(MOUNT_JS), "mount.js must read the selected file's text client-side via File.text()");
|
|
61
|
+
assert(/post\(importUrl,\s*\{\s*graphJson:/.test(MOUNT_JS), "the Import handler must POST the file text as graphJson to the import door");
|
|
62
|
+
// The <input> must actually be WIRED to the handler: without a change listener that invokes
|
|
63
|
+
// importFile(), the door + handler could stay intact while Import is inert (every other assertion
|
|
64
|
+
// here still green). Pin the change→importFile wiring so removing the listener fails the suite.
|
|
65
|
+
assert(
|
|
66
|
+
/addEventListener\(\s*["']change["'][\s\S]{0,200}?importFile\(/.test(MOUNT_JS),
|
|
67
|
+
"the Import file input's change listener must invoke importFile() so picking a file triggers an import",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("#524: a successful import routes through #523's single fillComposer() seam", () => {
|
|
72
|
+
// #523 owns the inbound fill seam; #524 builds on it rather than adding a second inbound fill path.
|
|
73
|
+
assert(/function fillComposer\(/.test(MOUNT_JS), "the #523 fillComposer() seam must still be present");
|
|
74
|
+
const importHandler = MOUNT_JS.slice(MOUNT_JS.indexOf("async function importFile"));
|
|
75
|
+
assert(importHandler.length > 0, "mount.js must define the importFile handler");
|
|
76
|
+
assert(/fillComposer\(text\b/.test(importHandler), "a successful import must route the imported text through the fillComposer() seam");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("#524: an import failure renders the door's path-qualified compile errors inline", () => {
|
|
80
|
+
const importHandler = MOUNT_JS.slice(MOUNT_JS.indexOf("async function importFile"));
|
|
81
|
+
assert(
|
|
82
|
+
/renderErrors\(body\.error,\s*body\.errors\)/.test(importHandler),
|
|
83
|
+
"an import 400 must render the door's path-qualified errors inline",
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("#524: both shells forward importUrl into the mount (embed via cfg, standalone via ?import=)", () => {
|
|
88
|
+
// The mount resolves `config.importUrl ?? DEFAULT_IMPORT_URL`, so the default-URL guard above passes
|
|
89
|
+
// even if a shell drops the `importUrl` forwarding entirely — silently breaking embedded custom
|
|
90
|
+
// deployments and the standalone `?import=` override. Pin BOTH forwardings, exactly as the shells
|
|
91
|
+
// already forward previewUrl/stageUrl.
|
|
92
|
+
assert(
|
|
93
|
+
/importUrl:\s*cfg\.importUrl/.test(EMBED_HTML),
|
|
94
|
+
"embed.html must forward the console-injected importUrl (importUrl: cfg.importUrl) into the mount",
|
|
95
|
+
);
|
|
96
|
+
assert(
|
|
97
|
+
/importUrl:\s*params\.get\("import"\)/.test(STANDALONE_HTML),
|
|
98
|
+
"standalone.html must forward the ?import= override (importUrl: params.get(\"import\")) into the mount",
|
|
99
|
+
);
|
|
100
|
+
// mount.js must actually read config.importUrl (not hard-code the default), so the forwarding matters.
|
|
101
|
+
assert(
|
|
102
|
+
/const importUrl\s*=\s*config\.importUrl\s*\?\?/.test(MOUNT_JS),
|
|
103
|
+
"mount.js must resolve importUrl from config.importUrl with a fallback default",
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("#524: the native file input is visually hidden and the label reads as a button", () => {
|
|
108
|
+
assert(/\.dg-import-input/.test(CSS), "the CSS must style the Import file input");
|
|
109
|
+
assert(/clip:\s*rect\(0,\s*0,\s*0,\s*0\)/.test(CSS), "the native file input must be visually hidden (the label is the button)");
|
|
110
|
+
});
|