@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
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Contract guard for the reusable delivery-graph LIBRARY App-View (issue #523, epic #519 S4).
|
|
2
|
+
//
|
|
3
|
+
// The Library surface (pages/delivery-graphs/library.*) LISTS saved library entries (the `listLibrary`
|
|
4
|
+
// door) and, per row, offers Reuse (load the saved graph back into the compose textarea `#dg-json`) and
|
|
5
|
+
// Delete (the `deleteLibraryEntry` door). Reuse crosses the App-View iframe boundary: it drives the
|
|
6
|
+
// compose mount's NEW inbound fill seam over the shared `deliveryGraph.compose.fill` host-bridge
|
|
7
|
+
// message. Plus a Save-to-library affordance on the staged App-View (save-from-digest) and a
|
|
8
|
+
// Save-to-library row action on the dispatched/history grid. This test pins that wiring so it cannot
|
|
9
|
+
// silently regress: the sidecars mount the same module, the door defaults are module-anchored off
|
|
10
|
+
// import.meta.url (the #279/#467/#536 App-View resolution class), Reuse posts the ONE shared fill message, Delete hits the per-entry door,
|
|
11
|
+
// the compose mount adds the inbound fill listener, and the page carries the new Library App-View node.
|
|
12
|
+
import { test } from "node:test";
|
|
13
|
+
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { DG_COMPOSE_FILL_MESSAGE } from "../pages/delivery-graphs/mount.js";
|
|
16
|
+
|
|
17
|
+
const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
|
|
18
|
+
const DIR = `${ROOT}pages/delivery-graphs`;
|
|
19
|
+
const LIBRARY_JS = readFileSync(`${DIR}/library.mount.js`, "utf8");
|
|
20
|
+
const COMPOSE_JS = readFileSync(`${DIR}/mount.js`, "utf8");
|
|
21
|
+
const STAGED_JS = readFileSync(`${DIR}/staged.mount.js`, "utf8");
|
|
22
|
+
const EMBED_HTML = readFileSync(`${DIR}/library-embed.html`, "utf8");
|
|
23
|
+
const STANDALONE_HTML = readFileSync(`${DIR}/library-standalone.html`, "utf8");
|
|
24
|
+
const PAGE_JSON = readFileSync(`${ROOT}pages/delivery-graphs.page.json`, "utf8");
|
|
25
|
+
|
|
26
|
+
// Pull a MODULE-ANCHORED default spec out of `const <name> = config.<field> ?? <CONST>;` where the
|
|
27
|
+
// CONST is declared `const <CONST> = new URL("<spec>", import.meta.url).href;` (#467/#536).
|
|
28
|
+
function defaultSpec(src: string, name: string): string {
|
|
29
|
+
const m = src.match(new RegExp(`${name}\\s*=\\s*config\\.\\w+\\s*\\?\\?\\s*(\\w+);`));
|
|
30
|
+
assert(m, `mount must default ${name} from config with a fallback constant`);
|
|
31
|
+
const constM = src.match(
|
|
32
|
+
new RegExp(`const ${m![1]}\\s*=\\s*new URL\\(\\s*"([^"]*)"\\s*,\\s*import\\.meta\\.url\\s*\\)\\s*\\.href`),
|
|
33
|
+
);
|
|
34
|
+
assert(
|
|
35
|
+
constM,
|
|
36
|
+
`mount must default ${m![1]} to new URL("<spec>", import.meta.url) so the door is anchored to the ` +
|
|
37
|
+
`module's own served location, not the document base (#467/#536)`,
|
|
38
|
+
);
|
|
39
|
+
return constM![1];
|
|
40
|
+
}
|
|
41
|
+
test("#523: the Library App-View mounts the same module standalone and embedded", () => {
|
|
42
|
+
assert(/mountDeliveryGraphLibrary/.test(LIBRARY_JS), "library.mount.js must export mountDeliveryGraphLibrary");
|
|
43
|
+
for (const [file, html] of [["library-embed.html", EMBED_HTML], ["library-standalone.html", STANDALONE_HTML]] as const) {
|
|
44
|
+
assert(
|
|
45
|
+
/import \{ mountDeliveryGraphLibrary \} from "\.\/library\.mount\.js"/.test(html),
|
|
46
|
+
`${file} must import mountDeliveryGraphLibrary from ./library.mount.js`,
|
|
47
|
+
);
|
|
48
|
+
assert(/mountDeliveryGraphLibrary\(/.test(html), `${file} must call mountDeliveryGraphLibrary`);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("#523: the page binds the Library node to the library App-View sidecars", () => {
|
|
53
|
+
const page = JSON.parse(PAGE_JSON) as { nodes: Array<Record<string, any>> };
|
|
54
|
+
const library = page.nodes.find((n) => n.id === "delivery-graphs-library");
|
|
55
|
+
assert(library, "the page must carry the delivery-graphs-library node");
|
|
56
|
+
assert(library?.type === "appView", "delivery-graphs-library must be an appView (#523)");
|
|
57
|
+
assert(library?.props?.embed === "./delivery-graphs/library-embed.html", "it embeds the library embed sidecar");
|
|
58
|
+
assert(library?.props?.standalone === "./delivery-graphs/library-standalone.html", "it has the library standalone sidecar");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("#523/#467/#536: the Library list door default is module-anchored and hits the listLibrary door", () => {
|
|
62
|
+
const spec = defaultSpec(LIBRARY_JS, "libraryUrl");
|
|
63
|
+
assert(spec.endsWith("delivery-graph/library"), `libraryUrl default "${spec}" must hit the listLibrary door`);
|
|
64
|
+
assert(!spec.startsWith("/"), `default libraryUrl "${spec}" must not be absolute (App-View #279 resolution class)`);
|
|
65
|
+
assert(spec.startsWith("../"), `default libraryUrl "${spec}" must step up out of /delivery-graphs/ (module-anchored, #467/#536)`);
|
|
66
|
+
// The list read consumes the door's `entries` array (one row per saved entry).
|
|
67
|
+
assert(/body\.entries/.test(LIBRARY_JS), "library.mount.js must render one row per `entries[]` item the listLibrary door returns");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("#523: Reuse drives the compose fill seam over the shared host-bridge message", () => {
|
|
71
|
+
// Reuse loads the saved graph back into the SEPARATE compose App-View, so it posts the ONE shared
|
|
72
|
+
// fill message (its type imported from ./mount.js, never re-declared) UP over the App-View boundary.
|
|
73
|
+
assert(
|
|
74
|
+
/import \{ DG_COMPOSE_FILL_MESSAGE \} from "\.\/mount\.js"/.test(LIBRARY_JS),
|
|
75
|
+
"library.mount.js must import DG_COMPOSE_FILL_MESSAGE from ./mount.js (the ONE source of truth for the fill message type)",
|
|
76
|
+
);
|
|
77
|
+
assert(/data-reuse=/.test(LIBRARY_JS), "library.mount.js must render a per-row Reuse affordance carrying the entry id");
|
|
78
|
+
assert(/postMessage\(/.test(LIBRARY_JS), "Reuse must post the fill message across the App-View boundary");
|
|
79
|
+
assert(/type:\s*DG_COMPOSE_FILL_MESSAGE/.test(LIBRARY_JS), "the Reuse message must carry the shared DG_COMPOSE_FILL_MESSAGE type");
|
|
80
|
+
assert(/graphJson:\s*entry\.graph/.test(LIBRARY_JS), "the Reuse message must carry the saved entry's graph JSON");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("#523: Delete hits the per-entry deleteLibraryEntry door", () => {
|
|
84
|
+
assert(/data-delete=/.test(LIBRARY_JS), "library.mount.js must render a per-row Delete affordance carrying the entry id");
|
|
85
|
+
// The delete door is the per-entry path under the list door: DELETE .../delivery-graph/library/<id>.
|
|
86
|
+
assert(/method:\s*"DELETE"/.test(LIBRARY_JS), "Delete must issue an HTTP DELETE to the deleteLibraryEntry door");
|
|
87
|
+
assert(/encodeURIComponent\(/.test(LIBRARY_JS), "the delete path must URL-encode the entry id it appends to the library door");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("#523: the compose mount exposes an INBOUND reuse-fill seam (message → #dg-json)", () => {
|
|
91
|
+
// The compose mount previously had NO inbound prefill — its #dg-json was set only by Load-example /
|
|
92
|
+
// typing. S4 adds a same-origin message listener that fills #dg-json through a single fillComposer seam.
|
|
93
|
+
assert(/export const DG_COMPOSE_FILL_MESSAGE\s*=/.test(COMPOSE_JS), "mount.js must export the DG_COMPOSE_FILL_MESSAGE fill-message type");
|
|
94
|
+
// Pin the actual wire value the imported const carries: this is the cross-App-View host-bridge
|
|
95
|
+
// message type the Library/compose sidecars agree on, so a silent change to the string is a
|
|
96
|
+
// breaking wire-contract change and the compose mount must declare exactly that literal.
|
|
97
|
+
assertEquals(DG_COMPOSE_FILL_MESSAGE, "nano-delivery-graph-compose-fill", "the shared fill-message type must be the pinned wire-contract string");
|
|
98
|
+
assertStringIncludes(COMPOSE_JS, `"${DG_COMPOSE_FILL_MESSAGE}"`, "mount.js must declare the fill-message type as the pinned string literal");
|
|
99
|
+
assert(/function fillComposer\(/.test(COMPOSE_JS), "mount.js must define the single fillComposer seam every fill routes through");
|
|
100
|
+
assert(/addEventListener\("message"/.test(COMPOSE_JS), "mount.js must register an inbound `message` listener for the fill seam");
|
|
101
|
+
assert(/data\.type !== DG_COMPOSE_FILL_MESSAGE/.test(COMPOSE_JS), "the listener must gate on the shared fill-message type");
|
|
102
|
+
assert(/jsonEl\.value = graphJson/.test(COMPOSE_JS), "fillComposer must load the graph JSON into the #dg-json textarea");
|
|
103
|
+
// Same-origin guard: a foreign origin must not be able to drive the fill.
|
|
104
|
+
assert(/ev\.origin !== window\.location\.origin/.test(COMPOSE_JS), "the fill listener must reject cross-origin messages");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("#523: Save-to-library on the staged App-View posts save-from-digest", () => {
|
|
108
|
+
const spec = defaultSpec(STAGED_JS, "saveLibraryUrl");
|
|
109
|
+
assert(spec.endsWith("actions/delivery-graph/library/save"), `saveLibraryUrl default "${spec}" must hit the saveToLibrary door`);
|
|
110
|
+
assert(!spec.startsWith("/"), `default saveLibraryUrl "${spec}" must not be absolute (App-View #279 resolution class)`);
|
|
111
|
+
assert(spec.startsWith("../"), `default saveLibraryUrl "${spec}" must step up out of /delivery-graphs/ (module-anchored, #467/#536)`);
|
|
112
|
+
assert(/data-save-library=/.test(STAGED_JS), "staged.mount.js must render a per-row Save-to-library affordance carrying the digest");
|
|
113
|
+
// Save-from-digest: it posts { name, digest } — it must NOT compile or stage a raw graph (the #460
|
|
114
|
+
// operator boundary the staged view enforces stays intact).
|
|
115
|
+
assert(/post\(saveLibraryUrl,\s*\{\s*name:[^}]*digest:/.test(STAGED_JS), "Save-to-library must POST { name, digest } (save-from-digest) to the save door");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("#523: Save-to-library is offered on a dispatched/history grid row (save-from-dispatched)", () => {
|
|
119
|
+
const page = JSON.parse(PAGE_JSON) as { nodes: Array<Record<string, any>> };
|
|
120
|
+
const grid = page.nodes.find((n) => n.id === "delivery-graphs-inflight");
|
|
121
|
+
assert(grid, "the page must carry the delivery-graphs-inflight grid");
|
|
122
|
+
const action = (grid?.props?.rowActions ?? []).find((a: Record<string, any>) => a.label === "Save to library");
|
|
123
|
+
assert(action, "the in-flight grid must offer a `Save to library` row action");
|
|
124
|
+
assert(
|
|
125
|
+
action?.action?.path === "/app/api/actions/delivery-graph/library/save",
|
|
126
|
+
"the Save-to-library row action must post to the saveToLibrary door",
|
|
127
|
+
);
|
|
128
|
+
assert(
|
|
129
|
+
action?.action?.body?.digest === "{{row.digest}}" && action?.action?.body?.name === "{{row.title}}",
|
|
130
|
+
"the Save-to-library row action must save-from-digest, naming the entry from the run title",
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
test("#523: the x-hook-secret guard is gated on a same-origin door (no cross-origin secret exfil)", () => {
|
|
134
|
+
// A `?library=` / `?staged=` / `?preview=` override can point a door at a full `https://…` URL on a
|
|
135
|
+
// FOREIGN origin. The shared guard secret must NEVER ride along to an arbitrary host, so each mount
|
|
136
|
+
// attaches `x-hook-secret` only when the resolved door URL is same-origin.
|
|
137
|
+
for (const [name, src] of [
|
|
138
|
+
["library.mount.js", LIBRARY_JS],
|
|
139
|
+
["mount.js", COMPOSE_JS],
|
|
140
|
+
["staged.mount.js", STAGED_JS],
|
|
141
|
+
] as const) {
|
|
142
|
+
assert(/function isSameOrigin\(url\)/.test(src), `${name} must define an isSameOrigin(url) guard`);
|
|
143
|
+
assert(
|
|
144
|
+
/new URL\(url,\s*window\.location\.href\)\.origin === window\.location\.origin/.test(src),
|
|
145
|
+
`${name} isSameOrigin must compare the resolved URL origin against window.location.origin`,
|
|
146
|
+
);
|
|
147
|
+
assert(
|
|
148
|
+
/config\.hookSecret && isSameOrigin\(url\)\s*\?\s*\{\s*"x-hook-secret"/.test(src),
|
|
149
|
+
`${name} must attach x-hook-secret ONLY when hookSecret is set AND the door URL is same-origin`,
|
|
150
|
+
);
|
|
151
|
+
assert(
|
|
152
|
+
/headers:\s*headers\(url\)/.test(src),
|
|
153
|
+
`${name} must thread the request URL into headers(url) so the secret gate sees the target origin`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Contract guard for the delivery-graph LIBRARY Export affordance (issue #525, epic #519 S6).
|
|
2
|
+
//
|
|
3
|
+
// Export is a purely client-side Blob download of a saved library entry's stored graph JSON as
|
|
4
|
+
// `<name>.deliverygraph.json` — no backend door. It builds directly on the S4 Library App-View (#523,
|
|
5
|
+
// `pages/delivery-graphs/library.mount.js`): each rendered library entry gains an Export button
|
|
6
|
+
// alongside Reuse/Delete. This test pins BOTH halves so the affordance cannot silently regress:
|
|
7
|
+
// • a behavioural guard on the pure, DOM-free `buildDeliveryGraphExport()` helper — it "builds a
|
|
8
|
+
// download from the entry's graph JSON": the download contents are the entry's stored graph
|
|
9
|
+
// verbatim and the filename is the sanitised name + `.deliverygraph.json`; and
|
|
10
|
+
// • a source guard that the mount renders the per-row Export control and assembles the Blob download
|
|
11
|
+
// (createObjectURL → anchor.download → revokeObjectURL) from that helper.
|
|
12
|
+
import { test } from "node:test";
|
|
13
|
+
import { assert, assertEquals } from "#test-assert";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { buildDeliveryGraphExport, DELIVERY_GRAPH_EXPORT_SUFFIX } from "../pages/delivery-graphs/library.mount.js";
|
|
16
|
+
|
|
17
|
+
const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
|
|
18
|
+
const LIBRARY_JS = readFileSync(`${ROOT}pages/delivery-graphs/library.mount.js`, "utf8");
|
|
19
|
+
|
|
20
|
+
test("#525: the export filename suffix is the pinned .deliverygraph.json contract", () => {
|
|
21
|
+
assertEquals(DELIVERY_GRAPH_EXPORT_SUFFIX, ".deliverygraph.json");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("#525: buildDeliveryGraphExport downloads the entry's stored graph JSON verbatim", () => {
|
|
25
|
+
const graph = '{"nodes":[{"id":"a"}],"edges":[]}';
|
|
26
|
+
const out = buildDeliveryGraphExport({ id: "lib-1", name: "Onboarding", graph });
|
|
27
|
+
// "builds a download from the entry's graph JSON": bytes are the stored graph, unmodified.
|
|
28
|
+
assertEquals(out.contents, graph, "export contents must be the entry's stored graph JSON, byte-for-byte");
|
|
29
|
+
assertEquals(out.mime, "application/json", "an exported delivery graph is application/json");
|
|
30
|
+
assertEquals(out.filename, `Onboarding${DELIVERY_GRAPH_EXPORT_SUFFIX}`, "filename is <name>.deliverygraph.json");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("#525: the export filename sanitises unsafe characters in the entry name", () => {
|
|
34
|
+
const out = buildDeliveryGraphExport({ id: "lib-2", name: "My Graph / v2: final!", graph: "{}" });
|
|
35
|
+
assertEquals(out.filename, `My-Graph-v2-final${DELIVERY_GRAPH_EXPORT_SUFFIX}`);
|
|
36
|
+
// No path separators, and never a leading-dot hidden file.
|
|
37
|
+
assert(!out.filename.includes("/") && !out.filename.includes("\\"), "filename must carry no path separators");
|
|
38
|
+
assert(!out.filename.startsWith("."), "filename must not be a hidden dotfile");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("#525: an unnamed entry falls back to a stable id-derived filename", () => {
|
|
42
|
+
const named = buildDeliveryGraphExport({ id: "lib-3", name: " ", graph: "{}" });
|
|
43
|
+
assertEquals(named.filename, `delivery-graph-lib-3${DELIVERY_GRAPH_EXPORT_SUFFIX}`, "blank name falls back to the entry id");
|
|
44
|
+
const anon = buildDeliveryGraphExport({ graph: "{}" });
|
|
45
|
+
assertEquals(anon.filename, `delivery-graph${DELIVERY_GRAPH_EXPORT_SUFFIX}`, "no name and no id falls back to a constant");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("#525: an entry with no stored graph yields empty contents (nothing to download)", () => {
|
|
49
|
+
assertEquals(buildDeliveryGraphExport({ id: "x", name: "n" }).contents, "");
|
|
50
|
+
assertEquals(buildDeliveryGraphExport({ id: "x", name: "n", graph: 42 as unknown as string }).contents, "");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("#525: the Library mount renders a per-row Export control wired to a Blob download", () => {
|
|
54
|
+
assert(/data-export=/.test(LIBRARY_JS), "library.mount.js must render a per-row Export affordance carrying the entry id");
|
|
55
|
+
assert(/closest\("\[data-export\]"\)/.test(LIBRARY_JS), "the list click handler must route the Export button");
|
|
56
|
+
assert(/buildDeliveryGraphExport\(entry\)/.test(LIBRARY_JS), "Export must assemble the download from buildDeliveryGraphExport(entry)");
|
|
57
|
+
// The actual client-side download mechanics: Blob → object URL → anchor download → revoke.
|
|
58
|
+
assert(/new Blob\(\[contents\]/.test(LIBRARY_JS), "Export must build a Blob from the entry's graph contents");
|
|
59
|
+
assert(/URL\.createObjectURL\(/.test(LIBRARY_JS), "Export must create an object URL for the download");
|
|
60
|
+
assert(/\.download = filename/.test(LIBRARY_JS), "Export must set the anchor download filename");
|
|
61
|
+
assert(/URL\.revokeObjectURL\(/.test(LIBRARY_JS), "Export must revoke the object URL after triggering the download");
|
|
62
|
+
});
|
|
@@ -6,10 +6,21 @@
|
|
|
6
6
|
// Dispatch button and NO way to see the graph. The staged App-View (pages/delivery-graphs/staged.*)
|
|
7
7
|
// closes that: per row it offers Preview-DI (over the nano-navigate bridge) AND Dispatch. This test
|
|
8
8
|
// pins the wiring so it cannot silently regress: the sidecars exist and mount the same module, the door
|
|
9
|
-
// defaults are
|
|
10
|
-
//
|
|
9
|
+
// defaults are MODULE-ANCHORED (`new URL("../app/api/…", import.meta.url)`, the #467 App-View
|
|
10
|
+
// resolution class — see below), it drives the DI-preview bridge, and it posts the dispatch by digest.
|
|
11
|
+
//
|
|
12
|
+
// #279/#467 resolution class: the staged shell (staged-embed.html / staged-standalone.html) — and thus
|
|
13
|
+
// staged.mount.js — is served ONE DIRECTORY DEEP at `<appMount>/delivery-graphs/`, while the API lives
|
|
14
|
+
// at the app root, a sibling of `/delivery-graphs/`: `<appMount>/app/api/…`. A leading-slash absolute
|
|
15
|
+
// default resolves against the console iframe ORIGIN, not the app-view base (#279). A document-base-
|
|
16
|
+
// relative default (`"app/api/…"`) resolves against the `…/delivery-graphs/` shell base to
|
|
17
|
+
// `…/delivery-graphs/app/api/…` → 404 on EVERY surface (the Studio console never injects
|
|
18
|
+
// window.__NANO_APP_VIEW__, so the default runs) — the "Could not load staged proposals." bug (#536).
|
|
19
|
+
// The only correct default is anchored to staged.mount.js's OWN url (import.meta.url): `../app/api/…`
|
|
20
|
+
// steps up out of `/delivery-graphs/` onto `<appMount>/app/api/…` on all surfaces. This test pins that
|
|
21
|
+
// resolution against the REAL, `/delivery-graphs/`-deep module url so neither regression can return.
|
|
11
22
|
import { test } from "node:test";
|
|
12
|
-
import { assert } from "#test-assert";
|
|
23
|
+
import { assert, assertEquals } from "#test-assert";
|
|
13
24
|
import { readFileSync } from "node:fs";
|
|
14
25
|
|
|
15
26
|
const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
|
|
@@ -19,15 +30,55 @@ const EMBED_HTML = readFileSync(`${DIR}/staged-embed.html`, "utf8");
|
|
|
19
30
|
const STANDALONE_HTML = readFileSync(`${DIR}/staged-standalone.html`, "utf8");
|
|
20
31
|
const PAGE_JSON = readFileSync(`${ROOT}pages/delivery-graphs.page.json`, "utf8");
|
|
21
32
|
|
|
22
|
-
//
|
|
23
|
-
|
|
33
|
+
// The REAL served location of staged.mount.js on each surface: one directory deep under
|
|
34
|
+
// `/delivery-graphs/`.
|
|
35
|
+
const STANDALONE_MOUNT = "http://127.0.0.1:3000/delivery-graphs/staged.mount.js";
|
|
36
|
+
const STUDIO_MOUNT = "http://studio-host:8080/console/app-view/Workforce/delivery-graphs/staged.mount.js";
|
|
37
|
+
|
|
38
|
+
// Pull the module-anchored default spec out of `const <name> = config.<field> ?? <CONST>;` where the
|
|
39
|
+
// CONST is declared `const <CONST> = new URL("<spec>", import.meta.url).href;`.
|
|
40
|
+
function defaultSpec(name: string): string {
|
|
24
41
|
const m = MOUNT_JS.match(new RegExp(`${name}\\s*=\\s*config\\.\\w+\\s*\\?\\?\\s*(\\w+);`));
|
|
25
42
|
assert(m, `staged.mount.js must default ${name} from config with a fallback constant`);
|
|
26
|
-
const constM = MOUNT_JS.match(
|
|
27
|
-
|
|
43
|
+
const constM = MOUNT_JS.match(
|
|
44
|
+
new RegExp(`const ${m![1]}\\s*=\\s*new URL\\(\\s*"([^"]*)"\\s*,\\s*import\\.meta\\.url\\s*\\)\\s*\\.href`),
|
|
45
|
+
);
|
|
46
|
+
assert(
|
|
47
|
+
constM,
|
|
48
|
+
`staged.mount.js must default ${m![1]} to new URL("<spec>", import.meta.url) so the door is anchored ` +
|
|
49
|
+
`to the module's own served location, not the document base (#467/#536)`,
|
|
50
|
+
);
|
|
28
51
|
return constM![1];
|
|
29
52
|
}
|
|
30
53
|
|
|
54
|
+
// A module-anchored door default must (a) not be absolute (#279), (b) step up out of /delivery-graphs/
|
|
55
|
+
// (#467), and (c) resolve onto <appMount>/app/api/… both standalone AND inside the Studio iframe.
|
|
56
|
+
function assertModuleAnchored(name: string, endpoint: string): void {
|
|
57
|
+
const spec = defaultSpec(name);
|
|
58
|
+
assert(
|
|
59
|
+
!spec.startsWith("/"),
|
|
60
|
+
`default ${name} spec "${spec}" must not be absolute: a leading-slash path resolves against the ` +
|
|
61
|
+
`iframe ORIGIN (console :8080), not the app-view base, so every fetch 404s (#279)`,
|
|
62
|
+
);
|
|
63
|
+
assert(
|
|
64
|
+
spec.startsWith("../"),
|
|
65
|
+
`default ${name} spec "${spec}" must step up out of /delivery-graphs/ (import.meta.url points at ` +
|
|
66
|
+
`<appMount>/delivery-graphs/staged.mount.js; the API is a sibling at <appMount>/app/api/…) (#467)`,
|
|
67
|
+
);
|
|
68
|
+
assert(spec.endsWith(endpoint), `default ${name} spec "${spec}" must hit the ${endpoint} door`);
|
|
69
|
+
assertEquals(
|
|
70
|
+
new URL(spec, STANDALONE_MOUNT).href,
|
|
71
|
+
`http://127.0.0.1:3000/${endpoint}`,
|
|
72
|
+
`default ${name} must resolve to the app root, not the /delivery-graphs/ shell base (#467)`,
|
|
73
|
+
);
|
|
74
|
+
assertEquals(
|
|
75
|
+
new URL(spec, STUDIO_MOUNT).href,
|
|
76
|
+
`http://studio-host:8080/console/app-view/Workforce/${endpoint}`,
|
|
77
|
+
`default ${name} must resolve under the app-view base the console proxies, not the console origin ` +
|
|
78
|
+
`root (#279) nor the /delivery-graphs/ shell base (#467/#536)`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
31
82
|
test("#511: the staged App-View mounts the same module standalone and embedded", () => {
|
|
32
83
|
assert(/mountStagedProposals/.test(MOUNT_JS), "staged.mount.js must export mountStagedProposals");
|
|
33
84
|
for (const [file, html] of [["staged-embed.html", EMBED_HTML], ["staged-standalone.html", STANDALONE_HTML]] as const) {
|
|
@@ -48,25 +99,19 @@ test("#511: the page binds the Staged proposals node to the staged App-View side
|
|
|
48
99
|
assert(staged?.props?.standalone === "./delivery-graphs/staged-standalone.html", "it has the staged standalone sidecar");
|
|
49
100
|
});
|
|
50
101
|
|
|
51
|
-
test("#511/#279: the staged list door default is
|
|
52
|
-
|
|
53
|
-
assert(url.endsWith("delivery-graph/staged"), `stagedUrl default "${url}" must hit the listStagedProposals door`);
|
|
54
|
-
assert(!url.startsWith("/"), `default stagedUrl "${url}" must be base-relative (App-View #279 resolution class)`);
|
|
102
|
+
test("#511/#279/#467: the staged list door default is module-anchored to the app root", () => {
|
|
103
|
+
assertModuleAnchored("stagedUrl", "app/api/delivery-graph/staged");
|
|
55
104
|
});
|
|
56
105
|
|
|
57
106
|
test("#511: DI preview — the staged view wires the proposal-bpmn door and bridges the XML to the explorer", () => {
|
|
58
|
-
|
|
59
|
-
assert(url.endsWith("actions/delivery-graph/proposal-bpmn"), `proposalBpmnUrl default "${url}" must hit the previewProposalBpmn door`);
|
|
60
|
-
assert(!url.startsWith("/"), `default proposalBpmnUrl "${url}" must be base-relative`);
|
|
107
|
+
assertModuleAnchored("proposalBpmnUrl", "app/api/actions/delivery-graph/proposal-bpmn");
|
|
61
108
|
assert(/data-preview-di=/.test(MOUNT_JS), "staged.mount.js must render a per-row Preview-DI affordance carrying the digest");
|
|
62
109
|
assert(/target:\s*"definitionPreview"/.test(MOUNT_JS), "staged.mount.js must post nano-navigate to the definitionPreview target");
|
|
63
110
|
assert(/params:\s*\{\s*xml:/.test(MOUNT_JS), "staged.mount.js must carry the compiled BPMN xml in the bridge message");
|
|
64
111
|
});
|
|
65
112
|
|
|
66
113
|
test("#460/#511: Dispatch is the operator's launch — posts the digest to the dispatch door, and never compiles/stages", () => {
|
|
67
|
-
|
|
68
|
-
assert(url.endsWith("actions/delivery-graph/dispatch"), `dispatchUrl default "${url}" must hit the dispatchDeliveryGraph door`);
|
|
69
|
-
assert(!url.startsWith("/"), `default dispatchUrl "${url}" must be base-relative`);
|
|
114
|
+
assertModuleAnchored("dispatchUrl", "app/api/actions/delivery-graph/dispatch");
|
|
70
115
|
assert(/data-dispatch=/.test(MOUNT_JS), "staged.mount.js must render a per-row Dispatch affordance carrying the digest");
|
|
71
116
|
assert(/window\.confirm\(/.test(MOUNT_JS), "Dispatch must confirm before launching (dispatch authorises side effects)");
|
|
72
117
|
// Operator-only: this surface dispatches a digest that is ALREADY staged — it must not compile or
|
|
@@ -75,3 +120,10 @@ test("#460/#511: Dispatch is the operator's launch — posts the digest to the d
|
|
|
75
120
|
assert(!/graphJson/.test(MOUNT_JS), "staged.mount.js must NOT submit pasted graph JSON (it only lists+dispatches staged proposals)");
|
|
76
121
|
assert(!/approvalToken/.test(MOUNT_JS), "staged.mount.js must NOT carry the removed replayable approvalToken");
|
|
77
122
|
});
|
|
123
|
+
|
|
124
|
+
test("#520: Dismiss is the operator's discard — posts the digest to the dismiss door, behind a confirm, and launches nothing", () => {
|
|
125
|
+
assertModuleAnchored("dismissUrl", "app/api/actions/delivery-graph/dismiss");
|
|
126
|
+
assert(/data-dismiss=/.test(MOUNT_JS), "staged.mount.js must render a per-row Dismiss affordance carrying the digest");
|
|
127
|
+
// Dismiss is a one-way discard off the staged list — confirm before it drops the proposal.
|
|
128
|
+
assert(/window\.confirm\(DISMISS_CONFIRM\)/.test(MOUNT_JS), "staged.mount.js must confirm before dismissing (a one-way discard off the staged list)");
|
|
129
|
+
});
|