@nanobpm/nano-workforce 0.148.0 → 0.148.2
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/package.json +2 -1
- package/pages/delivery-graphs/delivery-graphs.css +36 -0
- package/pages/delivery-graphs/staged.mount.js +210 -45
- package/resources/prompts/feature.md +9 -2
- package/test/delivery-graphs-staged-confirm.test.ts +192 -0
- package/test/delivery-graphs-staged-embed.test.ts +20 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.148.2](https://github.com/nanobpm/nano-workforce/compare/v0.148.1...v0.148.2) (2026-08-27)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **delivery-graph:** in-DOM confirm for staged Dispatch/Dismiss/Save (App-View sandbox) ([#571](https://github.com/nanobpm/nano-workforce/issues/571)) ([6607399](https://github.com/nanobpm/nano-workforce/commit/6607399d0a172b878e50a05432091d8e97c7e224)), closes [#460](https://github.com/nanobpm/nano-workforce/issues/460) [#569](https://github.com/nanobpm/nano-workforce/issues/569)
|
|
6
|
+
|
|
7
|
+
## [0.148.1](https://github.com/nanobpm/nano-workforce/compare/v0.148.0...v0.148.1) (2026-08-26)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **prompts:** stop suggesting Depends-on: for parent-issue linking in feature.md ([#557](https://github.com/nanobpm/nano-workforce/issues/557)) ([3f698ba](https://github.com/nanobpm/nano-workforce/commit/3f698ba2c1ab4f43a154a8bf3f426aa343062ea4)), closes [Magikcraft/nano-bpm#988](https://github.com/Magikcraft/nano-bpm/issues/988) [#N](https://github.com/nanobpm/nano-workforce/issues/N) [#N](https://github.com/nanobpm/nano-workforce/issues/N) [#555](https://github.com/nanobpm/nano-workforce/issues/555)
|
|
12
|
+
|
|
1
13
|
## [0.148.0](https://github.com/nanobpm/nano-workforce/compare/v0.147.0...v0.148.0) (2026-08-26)
|
|
2
14
|
|
|
3
15
|
### Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.148.
|
|
3
|
+
"version": "0.148.2",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"@semantic-release/npm": "^13.1.5",
|
|
72
72
|
"@types/node": "^24.0.0",
|
|
73
73
|
"conventional-changelog-conventionalcommits": "^8.0.0",
|
|
74
|
+
"linkedom": "^0.18.13",
|
|
74
75
|
"semantic-release": "^25.0.0",
|
|
75
76
|
"typescript": "^5.6.0"
|
|
76
77
|
},
|
|
@@ -170,6 +170,42 @@
|
|
|
170
170
|
color: #8aa0b8;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/* The inline in-DOM confirmation (issue #569): a two-step Dispatch/Dismiss "Confirm / Cancel" pair, or
|
|
174
|
+
an inline Save-to-library name input, rendered by staged.mount.js in place of the native
|
|
175
|
+
window.confirm/window.prompt the sandboxed console App-View iframe suppresses. It sits in the same
|
|
176
|
+
.actions row as the buttons it replaces. */
|
|
177
|
+
.dg .confirm {
|
|
178
|
+
display: inline-flex;
|
|
179
|
+
align-items: center;
|
|
180
|
+
gap: 8px;
|
|
181
|
+
flex-wrap: wrap;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.dg .btn-danger {
|
|
185
|
+
background: #b5321f;
|
|
186
|
+
border-color: #d1442e;
|
|
187
|
+
color: #fff;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.dg .btn-danger:hover {
|
|
191
|
+
background: #d1442e;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.dg .confirm-msg {
|
|
195
|
+
color: #d7e0ea;
|
|
196
|
+
font-size: 13px;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.dg .confirm-input {
|
|
200
|
+
background: #0f1621;
|
|
201
|
+
color: #d7e0ea;
|
|
202
|
+
border: 1px solid #2b3a4e;
|
|
203
|
+
border-radius: 7px;
|
|
204
|
+
padding: 7px 12px;
|
|
205
|
+
font-size: 13px;
|
|
206
|
+
min-width: 200px;
|
|
207
|
+
}
|
|
208
|
+
|
|
173
209
|
/* The filesystem Import control (issue #524): a <label class="btn"> wrapping a visually-hidden native
|
|
174
210
|
<input type=file>, so the file picker reads as a button consistent with the other actions. The label
|
|
175
211
|
dims while its input is disabled (busy) via :has(), mirroring .btn:disabled. */
|
|
@@ -54,6 +54,11 @@ const REQUEST_TIMEOUT_MS = 30000;
|
|
|
54
54
|
|
|
55
55
|
// The confirm shown before a dispatch — dispatching authorises every side-effecting node, so the
|
|
56
56
|
// operator acknowledges that the launch (and its side effects) is content-addressed to this graph.
|
|
57
|
+
// This is rendered as an IN-DOM inline confirmation step (NOT a native window.confirm): the console
|
|
58
|
+
// loads this view in a sandboxed App-View iframe with no `allow-modals`, where window.confirm is
|
|
59
|
+
// silently suppressed (returns false) — so a native-modal gate reads as "operator declined" and the
|
|
60
|
+
// button no-ops (#569). The two-step inline control keeps the #460 "the click IS the approval" UX
|
|
61
|
+
// without depending on a host capability the App-View iframe doesn't grant.
|
|
57
62
|
const DISPATCH_CONFIRM =
|
|
58
63
|
"Dispatch this staged delivery graph? This launches the graph engine-natively — any side-effecting " +
|
|
59
64
|
"node (it merges PRs / publishes packages) will run. Clicking Dispatch IS the approval, " +
|
|
@@ -61,7 +66,8 @@ const DISPATCH_CONFIRM =
|
|
|
61
66
|
|
|
62
67
|
// The confirm shown before a dismiss — dismissing is a terminal discard: the proposal drops off the
|
|
63
68
|
// 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.
|
|
69
|
+
// lighter acknowledgement than Dispatch, but still a one-way action the operator confirms. Rendered
|
|
70
|
+
// as the same IN-DOM inline confirmation as Dispatch (no native window.confirm), for the #569 reason.
|
|
65
71
|
const DISMISS_CONFIRM =
|
|
66
72
|
"Dismiss this staged delivery graph? It is discarded as noise and drops off the staged list — this " +
|
|
67
73
|
"launches nothing, but to bring it back you must recompile/re-stage it.";
|
|
@@ -81,8 +87,56 @@ function fmtTime(iso) {
|
|
|
81
87
|
return esc(new Date(t).toLocaleString());
|
|
82
88
|
}
|
|
83
89
|
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
// Is `pending` an open inline confirmation for `kind` on this proposal's row? `pending` is the single
|
|
91
|
+
// open confirmation (or null) — see mountStagedProposals. Only the matching row+kind swaps its button
|
|
92
|
+
// for the two-step inline control; every other row keeps its plain button.
|
|
93
|
+
function isPending(pending, kind, digest) {
|
|
94
|
+
return pending != null && pending.kind === kind && pending.digest === digest;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The Dispatch affordance for a row: normally a single button; while its inline confirmation is open,
|
|
98
|
+
// an in-DOM "Confirm dispatch / Cancel" pair (NOT a native window.confirm, suppressed in the sandboxed
|
|
99
|
+
// App-View iframe — #569). Clicking "Confirm dispatch" IS the operator approval (#460).
|
|
100
|
+
function dispatchControl(p, pending) {
|
|
101
|
+
if (isPending(pending, "dispatch", p.digest)) {
|
|
102
|
+
return `<span class="confirm" data-confirm="dispatch">
|
|
103
|
+
<span class="confirm-msg">${esc(DISPATCH_CONFIRM)}</span>
|
|
104
|
+
<button class="btn btn-primary" type="button" data-dispatch-confirm="${esc(p.digest)}">Confirm dispatch</button>
|
|
105
|
+
<button class="btn btn-ghost" type="button" data-dispatch-cancel="${esc(p.digest)}">Cancel</button>
|
|
106
|
+
</span>`;
|
|
107
|
+
}
|
|
108
|
+
return `<button class="btn btn-primary" type="button" data-dispatch="${esc(p.digest)}">Dispatch</button>`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The Dismiss affordance: a plain button, or — while open — the in-DOM "Confirm dismiss / Cancel" pair.
|
|
112
|
+
function dismissControl(p, pending) {
|
|
113
|
+
if (isPending(pending, "dismiss", p.digest)) {
|
|
114
|
+
return `<span class="confirm" data-confirm="dismiss">
|
|
115
|
+
<span class="confirm-msg">${esc(DISMISS_CONFIRM)}</span>
|
|
116
|
+
<button class="btn btn-danger" type="button" data-dismiss-confirm="${esc(p.digest)}">Confirm dismiss</button>
|
|
117
|
+
<button class="btn btn-ghost" type="button" data-dismiss-cancel="${esc(p.digest)}">Cancel</button>
|
|
118
|
+
</span>`;
|
|
119
|
+
}
|
|
120
|
+
return `<button class="btn btn-ghost" type="button" data-dismiss="${esc(p.digest)}">Dismiss</button>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The Save-to-library affordance: a plain button, or — while open — an in-DOM text input for the entry
|
|
124
|
+
// name plus Save/Cancel (replacing the native window.prompt, which returns null under sandbox — #569).
|
|
125
|
+
function saveControl(p, pending) {
|
|
126
|
+
if (isPending(pending, "save", p.digest)) {
|
|
127
|
+
const name = typeof pending.name === "string" ? pending.name : "";
|
|
128
|
+
return `<span class="confirm" data-confirm="save">
|
|
129
|
+
<input class="confirm-input" type="text" data-library-name="${esc(p.digest)}" value="${esc(name)}" placeholder="Library entry name" aria-label="Library entry name" />
|
|
130
|
+
<button class="btn btn-primary" type="button" data-save-library-confirm="${esc(p.digest)}">Save</button>
|
|
131
|
+
<button class="btn btn-ghost" type="button" data-save-library-cancel="${esc(p.digest)}">Cancel</button>
|
|
132
|
+
</span>`;
|
|
133
|
+
}
|
|
134
|
+
return `<button class="btn btn-ghost" type="button" data-save-library="${esc(p.digest)}" data-title="${esc(p.title ?? "")}">Save to library</button>`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Render one staged proposal as a card row with Preview-DI + Dispatch actions (and any open
|
|
138
|
+
* in-DOM confirmation, keyed by `pending`). */
|
|
139
|
+
function renderProposal(p, pending) {
|
|
86
140
|
const title = p.title ? `<code>${esc(p.title)}</code>` : '<span class="muted">(unnamed)</span>';
|
|
87
141
|
const gate = p.sideEffecting
|
|
88
142
|
? '<span class="pill pill-connector">side-effecting</span>'
|
|
@@ -99,15 +153,15 @@ function renderProposal(p) {
|
|
|
99
153
|
</div>
|
|
100
154
|
<div class="actions">
|
|
101
155
|
<button class="btn btn-ghost" type="button" data-preview-di="${esc(p.digest)}">Preview generated DI</button>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
156
|
+
${dispatchControl(p, pending)}
|
|
157
|
+
${saveControl(p, pending)}
|
|
158
|
+
${dismissControl(p, pending)}
|
|
105
159
|
</div>
|
|
106
160
|
</section>`;
|
|
107
161
|
}
|
|
108
162
|
|
|
109
|
-
/** Render the whole list (or the empty state). */
|
|
110
|
-
function renderList(proposals) {
|
|
163
|
+
/** Render the whole list (or the empty state), threading the single open in-DOM confirmation. */
|
|
164
|
+
function renderList(proposals, pending) {
|
|
111
165
|
if (!Array.isArray(proposals) || proposals.length === 0) {
|
|
112
166
|
return `<section class="card">
|
|
113
167
|
<h2>Staged proposals <span class="count">0</span></h2>
|
|
@@ -118,7 +172,7 @@ function renderList(proposals) {
|
|
|
118
172
|
<h2>Staged proposals <span class="count">${proposals.length}</span></h2>
|
|
119
173
|
<p class="ok">Awaiting an operator. <b>Preview generated DI</b> renders the laid-out BPMN in the process explorer; <b>Dispatch</b> launches it (dispatch is the approval, #460).</p>
|
|
120
174
|
</section>`;
|
|
121
|
-
return header + proposals.map(renderProposal).join("");
|
|
175
|
+
return header + proposals.map((p) => renderProposal(p, pending)).join("");
|
|
122
176
|
}
|
|
123
177
|
|
|
124
178
|
// Only attach the guard secret when the resolved door URL is SAME-ORIGIN. The staged/dispatch/dismiss/
|
|
@@ -172,6 +226,14 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
172
226
|
}
|
|
173
227
|
|
|
174
228
|
let busyCount = 0;
|
|
229
|
+
// The proposals from the last successful load — kept so an in-DOM confirmation opened/closed by a
|
|
230
|
+
// click can re-render the SAME list synchronously (without waiting for the next poll).
|
|
231
|
+
let currentProposals = [];
|
|
232
|
+
// The single open in-DOM confirmation, or null. Shape: { kind: "dispatch"|"dismiss"|"save",
|
|
233
|
+
// digest: string, name?: string }. This REPLACES the native window.confirm/window.prompt the console's
|
|
234
|
+
// sandboxed App-View iframe suppresses (#569): the operator approval is an inline two-step control
|
|
235
|
+
// (Confirm/Cancel) or an inline name input, rendered by renderProposal from this state.
|
|
236
|
+
let pending = null;
|
|
175
237
|
// A re-render (renderList → new buttons) resets every button to enabled, so the disabled state is
|
|
176
238
|
// NOT stored on the elements — it is derived from busyCount and re-applied after each render (below)
|
|
177
239
|
// and on every busy()/idle() transition. That keeps a poll or dispatch-driven refresh from silently
|
|
@@ -184,6 +246,12 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
184
246
|
busyCount += on ? 1 : -1;
|
|
185
247
|
applyDisabled();
|
|
186
248
|
}
|
|
249
|
+
// Re-render the list from the last-known proposals, threading the open confirmation. Used by the
|
|
250
|
+
// click handlers that open/close an inline confirmation so it appears/disappears immediately.
|
|
251
|
+
function rerender() {
|
|
252
|
+
listEl.innerHTML = renderList(currentProposals, pending);
|
|
253
|
+
applyDisabled();
|
|
254
|
+
}
|
|
187
255
|
|
|
188
256
|
/** Fetch JSON from a door and return { status, body } (never throws on an HTTP error). Rejects
|
|
189
257
|
* (AbortError) if the request outlives REQUEST_TIMEOUT_MS so a hung door can't wedge the busy lock. */
|
|
@@ -217,14 +285,24 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
217
285
|
const { status, body } = await get(stagedUrl);
|
|
218
286
|
if (disposed) return;
|
|
219
287
|
if (status === 200 && Array.isArray(body.proposals)) {
|
|
220
|
-
|
|
221
|
-
|
|
288
|
+
currentProposals = body.proposals;
|
|
289
|
+
// If the proposal an inline confirmation is open over has since dropped off the list (dispatched
|
|
290
|
+
// elsewhere, dismissed, or expired), the confirmation is stale — clear it so the poll can redraw.
|
|
291
|
+
if (pending != null && !body.proposals.some((p) => p.digest === pending.digest)) pending = null;
|
|
292
|
+
// While a confirmation (or its name input) is open, DON'T clobber the DOM the operator is
|
|
293
|
+
// interacting with — the fetch above still runs, so an expiry is detected and cleared above.
|
|
294
|
+
if (pending == null) {
|
|
295
|
+
listEl.innerHTML = renderList(body.proposals, pending);
|
|
296
|
+
applyDisabled();
|
|
297
|
+
}
|
|
222
298
|
if (loadErrorShown) {
|
|
223
299
|
setStatus("");
|
|
224
300
|
loadErrorShown = false;
|
|
225
301
|
}
|
|
226
302
|
} else {
|
|
227
|
-
|
|
303
|
+
currentProposals = [];
|
|
304
|
+
pending = null;
|
|
305
|
+
listEl.innerHTML = renderList([], pending);
|
|
228
306
|
applyDisabled();
|
|
229
307
|
setStatus(body && body.error ? body.error : "Could not load staged proposals.", "err");
|
|
230
308
|
loadErrorShown = true;
|
|
@@ -270,15 +348,13 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
270
348
|
}
|
|
271
349
|
}
|
|
272
350
|
|
|
273
|
-
// "Dispatch": the operator's launch (#460).
|
|
274
|
-
//
|
|
275
|
-
//
|
|
351
|
+
// "Dispatch": the operator's launch (#460). The confirmation is an IN-DOM two-step control (opened by
|
|
352
|
+
// the click handler, #569) — by the time we're here the operator has clicked "Confirm dispatch", so we
|
|
353
|
+
// POST the digest to the dispatch door; on success the proposal flips to `dispatched` and drops off
|
|
354
|
+
// the list on the next poll — refresh immediately so the operator sees it leave.
|
|
276
355
|
async function doDispatch(digest) {
|
|
277
356
|
const staged = typeof digest === "string" ? digest.trim() : "";
|
|
278
357
|
if (staged === "") return;
|
|
279
|
-
if (typeof window !== "undefined" && typeof window.confirm === "function" && !window.confirm(DISPATCH_CONFIRM)) {
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
358
|
busy(true);
|
|
283
359
|
setStatus("Dispatching…");
|
|
284
360
|
try {
|
|
@@ -296,15 +372,13 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
296
372
|
}
|
|
297
373
|
}
|
|
298
374
|
|
|
299
|
-
// "Dismiss": the operator's discard (#520).
|
|
300
|
-
//
|
|
301
|
-
// the list on the next poll — refresh immediately so the operator sees it
|
|
375
|
+
// "Dismiss": the operator's discard (#520). Confirmed via the same IN-DOM two-step control as Dispatch
|
|
376
|
+
// (#569); on the confirm we POST the digest to the dismiss door; on success the proposal flips to
|
|
377
|
+
// `dismissed` and drops off the list on the next poll — refresh immediately so the operator sees it
|
|
378
|
+
// leave. Launches nothing.
|
|
302
379
|
async function doDismiss(digest) {
|
|
303
380
|
const staged = typeof digest === "string" ? digest.trim() : "";
|
|
304
381
|
if (staged === "") return;
|
|
305
|
-
if (typeof window !== "undefined" && typeof window.confirm === "function" && !window.confirm(DISMISS_CONFIRM)) {
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
382
|
busy(true);
|
|
309
383
|
setStatus("Dismissing…");
|
|
310
384
|
try {
|
|
@@ -323,20 +397,15 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
323
397
|
}
|
|
324
398
|
|
|
325
399
|
// "Save to library": copy this staged proposal's already-stored graph into the reusable library
|
|
326
|
-
// (issue #523, save-from-digest → source `from-staged`).
|
|
327
|
-
// (defaulting to the proposal title — its slug/short-hash derive the library id, so
|
|
328
|
-
// same name upserts),
|
|
329
|
-
//
|
|
330
|
-
// is untouched.
|
|
331
|
-
async function doSaveToLibrary(digest,
|
|
400
|
+
// (issue #523, save-from-digest → source `from-staged`). The entry name comes from an IN-DOM inline
|
|
401
|
+
// text input (defaulting to the proposal title — its slug/short-hash derive the library id, so
|
|
402
|
+
// re-saving the same name upserts), NOT a native window.prompt (suppressed under the App-View sandbox,
|
|
403
|
+
// #569). We POST { name, digest } to the save door. This persists a library entry only — it never
|
|
404
|
+
// dispatches or re-stages, so the operator boundary the staged view enforces (#460) is untouched.
|
|
405
|
+
async function doSaveToLibrary(digest, rawName) {
|
|
332
406
|
const staged = typeof digest === "string" ? digest.trim() : "";
|
|
333
407
|
if (staged === "") return;
|
|
334
|
-
|
|
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
|
-
}
|
|
408
|
+
const name = typeof rawName === "string" ? rawName : "";
|
|
340
409
|
if (name.trim() === "") {
|
|
341
410
|
setStatus("A library entry needs a non-blank name.", "err");
|
|
342
411
|
return;
|
|
@@ -357,36 +426,132 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
357
426
|
}
|
|
358
427
|
}
|
|
359
428
|
|
|
429
|
+
// Open an inline confirmation for `kind` on `digest` (Dispatch/Dismiss) or the inline name input
|
|
430
|
+
// (Save) — replacing the native modal the sandboxed App-View iframe suppresses (#569).
|
|
431
|
+
function openConfirm(kind, digest, name) {
|
|
432
|
+
const staged = typeof digest === "string" ? digest.trim() : "";
|
|
433
|
+
if (staged === "") return;
|
|
434
|
+
pending = { kind, digest: staged, name: typeof name === "string" ? name : "" };
|
|
435
|
+
rerender();
|
|
436
|
+
// Move focus into the name input so the operator can type immediately (best-effort; not all hosts
|
|
437
|
+
// implement focus()).
|
|
438
|
+
if (kind === "save") {
|
|
439
|
+
const input = listEl.querySelector(`[data-library-name="${cssAttr(staged)}"]`);
|
|
440
|
+
if (input && typeof input.focus === "function") input.focus();
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Close any open inline confirmation and redraw the plain buttons.
|
|
445
|
+
function closeConfirm() {
|
|
446
|
+
pending = null;
|
|
447
|
+
rerender();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// The digest values are content-address hex (safe for a CSS attribute-selector), but escape a double
|
|
451
|
+
// quote defensively so a crafted value can't break out of the [data-library-name="…"] selector.
|
|
452
|
+
function cssAttr(value) {
|
|
453
|
+
return String(value).replace(/["\\]/g, "\\$&");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Read the current text of the inline library-name input for `digest` (empty string if it's gone).
|
|
457
|
+
function readLibraryName(digest) {
|
|
458
|
+
const input = listEl.querySelector(`[data-library-name="${cssAttr(digest)}"]`);
|
|
459
|
+
return input && typeof input.value === "string" ? input.value : "";
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Keep pending.name in sync as the operator types, so a background poll re-render (or a later confirm)
|
|
463
|
+
// preserves what they've entered.
|
|
464
|
+
listEl.addEventListener("input", (ev) => {
|
|
465
|
+
const input = ev.target && ev.target.closest ? ev.target.closest("[data-library-name]") : null;
|
|
466
|
+
if (input && pending != null && pending.kind === "save") {
|
|
467
|
+
pending.name = typeof input.value === "string" ? input.value : "";
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
|
|
360
471
|
listEl.addEventListener("click", (ev) => {
|
|
361
|
-
const
|
|
472
|
+
const closest = (sel) => (ev.target && ev.target.closest ? ev.target.closest(sel) : null);
|
|
473
|
+
|
|
474
|
+
const previewBtn = closest("[data-preview-di]");
|
|
362
475
|
if (previewBtn) {
|
|
363
476
|
ev.preventDefault();
|
|
364
477
|
doPreviewDi(previewBtn.getAttribute("data-preview-di"));
|
|
365
478
|
return;
|
|
366
479
|
}
|
|
367
|
-
|
|
480
|
+
|
|
481
|
+
// Dispatch: click opens the inline confirmation; "Confirm dispatch" performs the POST; "Cancel"
|
|
482
|
+
// closes it. The click IS the approval (#460), now gated on an in-DOM step, not window.confirm (#569).
|
|
483
|
+
const dispatchBtn = closest("[data-dispatch]");
|
|
368
484
|
if (dispatchBtn) {
|
|
369
485
|
ev.preventDefault();
|
|
370
|
-
|
|
486
|
+
openConfirm("dispatch", dispatchBtn.getAttribute("data-dispatch"));
|
|
371
487
|
return;
|
|
372
488
|
}
|
|
373
|
-
const
|
|
374
|
-
if (
|
|
489
|
+
const dispatchConfirmBtn = closest("[data-dispatch-confirm]");
|
|
490
|
+
if (dispatchConfirmBtn) {
|
|
375
491
|
ev.preventDefault();
|
|
376
|
-
|
|
492
|
+
const digest = dispatchConfirmBtn.getAttribute("data-dispatch-confirm");
|
|
493
|
+
closeConfirm();
|
|
494
|
+
doDispatch(digest);
|
|
377
495
|
return;
|
|
378
496
|
}
|
|
379
|
-
const
|
|
497
|
+
const dispatchCancelBtn = closest("[data-dispatch-cancel]");
|
|
498
|
+
if (dispatchCancelBtn) {
|
|
499
|
+
ev.preventDefault();
|
|
500
|
+
closeConfirm();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Dismiss: same two-step inline confirmation.
|
|
505
|
+
const dismissBtn = closest("[data-dismiss]");
|
|
380
506
|
if (dismissBtn) {
|
|
381
507
|
ev.preventDefault();
|
|
382
|
-
|
|
508
|
+
openConfirm("dismiss", dismissBtn.getAttribute("data-dismiss"));
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const dismissConfirmBtn = closest("[data-dismiss-confirm]");
|
|
512
|
+
if (dismissConfirmBtn) {
|
|
513
|
+
ev.preventDefault();
|
|
514
|
+
const digest = dismissConfirmBtn.getAttribute("data-dismiss-confirm");
|
|
515
|
+
closeConfirm();
|
|
516
|
+
doDismiss(digest);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const dismissCancelBtn = closest("[data-dismiss-cancel]");
|
|
520
|
+
if (dismissCancelBtn) {
|
|
521
|
+
ev.preventDefault();
|
|
522
|
+
closeConfirm();
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Save to library: the button opens an inline name input; "Save" reads it and POSTs; "Cancel" closes.
|
|
527
|
+
const saveLibraryBtn = closest("[data-save-library]");
|
|
528
|
+
if (saveLibraryBtn) {
|
|
529
|
+
ev.preventDefault();
|
|
530
|
+
openConfirm("save", saveLibraryBtn.getAttribute("data-save-library"), saveLibraryBtn.getAttribute("data-title"));
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const saveConfirmBtn = closest("[data-save-library-confirm]");
|
|
534
|
+
if (saveConfirmBtn) {
|
|
535
|
+
ev.preventDefault();
|
|
536
|
+
const digest = saveConfirmBtn.getAttribute("data-save-library-confirm");
|
|
537
|
+
const name = readLibraryName(digest);
|
|
538
|
+
closeConfirm();
|
|
539
|
+
doSaveToLibrary(digest, name);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const saveCancelBtn = closest("[data-save-library-cancel]");
|
|
543
|
+
if (saveCancelBtn) {
|
|
544
|
+
ev.preventDefault();
|
|
545
|
+
closeConfirm();
|
|
383
546
|
}
|
|
384
547
|
});
|
|
385
548
|
|
|
386
549
|
refresh();
|
|
387
550
|
// Skip a scheduled poll while a Preview/Dispatch request is in flight: re-rendering the list mid-
|
|
388
551
|
// request would drop the in-flight button (and its disabled state) out from under the user. The
|
|
389
|
-
// dispatch path drives its own refresh() on completion, so nothing is missed.
|
|
552
|
+
// dispatch path drives its own refresh() on completion, so nothing is missed. While an inline
|
|
553
|
+
// confirmation is open the poll still fetches (so an expiry is detected) but refresh() leaves the DOM
|
|
554
|
+
// the operator is interacting with untouched (see refresh).
|
|
390
555
|
const timer = setInterval(() => {
|
|
391
556
|
if (busyCount === 0) refresh();
|
|
392
557
|
}, refreshMs);
|
|
@@ -80,8 +80,15 @@ against the wrong base will not be merged into the epic.
|
|
|
80
80
|
3. Implement `task.prompt`. Keep the change scoped to this slice only.
|
|
81
81
|
4. Commit (sign off — this repo family enforces DCO: `git commit -s`), push the
|
|
82
82
|
branch, and open a pull request with `gh pr create` describing the slice and
|
|
83
|
-
linking the parent issue
|
|
84
|
-
|
|
83
|
+
linking the parent issue with a **non-blocking** ref — `Refs #N` / `Part of #N`,
|
|
84
|
+
or `Closes #N` when you delivered its full scope (but read the scope-split rule
|
|
85
|
+
below before you reach for `Closes`). **Never link a parent issue with
|
|
86
|
+
`Depends-on:`** — that keyword is a merge barrier (scraped into the PR's
|
|
87
|
+
merge-dependency set and honored natively by Mergify), so pointing it at an
|
|
88
|
+
issue registers a bogus, unmergeable dependency. Reserve `Depends-on:` strictly
|
|
89
|
+
for real PR→PR merge barriers — i.e. only when this PR genuinely cannot merge
|
|
90
|
+
until another *PR* lands first, written as `Depends-on: owner/repo#123` (never
|
|
91
|
+
pointing at an issue).
|
|
85
92
|
5. Clean up any scratch clone/worktree you created outside the commit.
|
|
86
93
|
|
|
87
94
|
## Closing keywords vs. scope splits — don't close a broader-scoped parent
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Behavioural guard for the Staged proposals App-View confirmation path (issue #569).
|
|
2
|
+
//
|
|
3
|
+
// The console loads this view inside a SANDBOXED App-View iframe with no `allow-modals`, where the
|
|
4
|
+
// browser SILENTLY SUPPRESSES window.confirm/window.prompt — the call returns false/null. The old mount
|
|
5
|
+
// gated Dispatch/Dismiss on `!window.confirm(...)` and Save-to-library on `window.prompt(...) === null`,
|
|
6
|
+
// so under the sandbox the suppressed false/null read as "operator declined/cancelled" and the button
|
|
7
|
+
// silently no-op'd: no POST, no banner (#569). This test drives the REAL mount over a real (linkedom)
|
|
8
|
+
// DOM with window.confirm/window.prompt STUBBED TO THE SANDBOX BEHAVIOUR (false/null), and asserts the
|
|
9
|
+
// side-effecting POST still fires — because the approval is now an IN-DOM two-step control, not a native
|
|
10
|
+
// modal. It fails against the pre-fix mount (no in-DOM Confirm affordance ever appears, no POST).
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import { assert, assertEquals } from "#test-assert";
|
|
13
|
+
import { parseHTML } from "linkedom";
|
|
14
|
+
import { mountStagedProposals } from "../pages/delivery-graphs/staged.mount.js";
|
|
15
|
+
|
|
16
|
+
const STAGED_URL = "https://app.test/app/api/delivery-graph/staged";
|
|
17
|
+
const DISPATCH_URL = "https://app.test/app/api/actions/delivery-graph/dispatch";
|
|
18
|
+
const DISMISS_URL = "https://app.test/app/api/actions/delivery-graph/dismiss";
|
|
19
|
+
const SAVE_URL = "https://app.test/app/api/actions/delivery-graph/library/save";
|
|
20
|
+
|
|
21
|
+
const PROPOSAL = {
|
|
22
|
+
digest: "abc123def456",
|
|
23
|
+
title: "My delivery graph",
|
|
24
|
+
sideEffecting: true,
|
|
25
|
+
nodeCount: 3,
|
|
26
|
+
humanNodeCount: 1,
|
|
27
|
+
sideEffectCount: 1,
|
|
28
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
29
|
+
expiresAt: "2026-01-02T00:00:00.000Z",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
interface FetchCall {
|
|
33
|
+
url: string;
|
|
34
|
+
method: string;
|
|
35
|
+
body: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Boot the real mount over a linkedom DOM whose `window` mimics a sandboxed App-View iframe (native
|
|
39
|
+
* confirm/prompt SUPPRESSED → false/null), with a recording fetch double. */
|
|
40
|
+
function harness() {
|
|
41
|
+
const { window: domWindow, document } = parseHTML(
|
|
42
|
+
"<!doctype html><html><body><div id='host'></div></body></html>",
|
|
43
|
+
);
|
|
44
|
+
const host = document.getElementById("host");
|
|
45
|
+
assert(host, "harness host element must exist");
|
|
46
|
+
|
|
47
|
+
const calls: FetchCall[] = [];
|
|
48
|
+
const origFetch = globalThis.fetch;
|
|
49
|
+
globalThis.fetch = async (input, init) => {
|
|
50
|
+
const url = String(input);
|
|
51
|
+
const method = init && typeof init.method === "string" ? init.method : "GET";
|
|
52
|
+
const body = init && typeof init.body === "string" ? init.body : "";
|
|
53
|
+
calls.push({ url, method, body });
|
|
54
|
+
if (method === "GET" && url === STAGED_URL) {
|
|
55
|
+
return new Response(JSON.stringify({ proposals: [PROPOSAL] }), { status: 200 });
|
|
56
|
+
}
|
|
57
|
+
if (url === DISPATCH_URL) return new Response(JSON.stringify({ ok: true }), { status: 202 });
|
|
58
|
+
if (url === DISMISS_URL) return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
59
|
+
if (url === SAVE_URL) return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
60
|
+
return new Response(JSON.stringify({}), { status: 404 });
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// A sandboxed iframe with no `allow-modals`: confirm() returns false, prompt() returns null. If the
|
|
64
|
+
// mount depended on either, the action would silently no-op — which is exactly the #569 bug.
|
|
65
|
+
const sandboxWindow = {
|
|
66
|
+
confirm: () => false,
|
|
67
|
+
prompt: () => null,
|
|
68
|
+
location: { href: "https://app.test/delivery-graphs/", origin: "https://app.test" },
|
|
69
|
+
parent: undefined as unknown,
|
|
70
|
+
};
|
|
71
|
+
sandboxWindow.parent = sandboxWindow; // parent === window ⇒ not embedded (no host explorer to drive)
|
|
72
|
+
const origWindow = Reflect.get(globalThis, "window");
|
|
73
|
+
Reflect.set(globalThis, "window", sandboxWindow);
|
|
74
|
+
|
|
75
|
+
const dispose = mountStagedProposals(host, {
|
|
76
|
+
stagedUrl: STAGED_URL,
|
|
77
|
+
dispatchUrl: DISPATCH_URL,
|
|
78
|
+
dismissUrl: DISMISS_URL,
|
|
79
|
+
saveLibraryUrl: SAVE_URL,
|
|
80
|
+
refreshMs: 1_000_000_000, // effectively no background poll during the test
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const teardown = () => {
|
|
84
|
+
dispose();
|
|
85
|
+
globalThis.fetch = origFetch;
|
|
86
|
+
Reflect.set(globalThis, "window", origWindow);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const flush = async () => {
|
|
90
|
+
for (let i = 0; i < 4; i++) await new Promise((resolve) => setTimeout(resolve, 0));
|
|
91
|
+
};
|
|
92
|
+
const click = (el: { dispatchEvent: (ev: unknown) => boolean }) =>
|
|
93
|
+
el.dispatchEvent(new domWindow.Event("click", { bubbles: true, cancelable: true }));
|
|
94
|
+
|
|
95
|
+
return { host, calls, teardown, flush, click };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const posts = (calls: FetchCall[], url: string) => calls.filter((c) => c.method === "POST" && c.url === url);
|
|
99
|
+
|
|
100
|
+
test("#569: Dispatch dispatches via the in-DOM confirmation even when native window.confirm is suppressed", async () => {
|
|
101
|
+
const h = harness();
|
|
102
|
+
try {
|
|
103
|
+
await h.flush();
|
|
104
|
+
const dispatchBtn = h.host.querySelector("[data-dispatch]");
|
|
105
|
+
assert(dispatchBtn, "the mount must render a Dispatch button for the staged proposal");
|
|
106
|
+
|
|
107
|
+
// Click Dispatch — with a suppressed native confirm, the PRE-FIX mount POSTs nothing; the fixed
|
|
108
|
+
// mount instead reveals an in-DOM "Confirm dispatch" step and has NOT yet POSTed.
|
|
109
|
+
h.click(dispatchBtn);
|
|
110
|
+
await h.flush();
|
|
111
|
+
assertEquals(posts(h.calls, DISPATCH_URL).length, 0, "Dispatch must not POST before the operator confirms in-DOM");
|
|
112
|
+
const confirmBtn = h.host.querySelector("[data-dispatch-confirm]");
|
|
113
|
+
assert(confirmBtn, "clicking Dispatch must reveal an in-DOM Confirm-dispatch control (#569), not call window.confirm");
|
|
114
|
+
assert(!h.host.querySelector("[data-dispatch]"), "the plain Dispatch button is replaced by the inline confirmation while it is open");
|
|
115
|
+
|
|
116
|
+
// Confirm — the digest is POSTed to the dispatch door, with no reliance on a native modal.
|
|
117
|
+
h.click(confirmBtn);
|
|
118
|
+
await h.flush();
|
|
119
|
+
const dispatched = posts(h.calls, DISPATCH_URL);
|
|
120
|
+
assertEquals(dispatched.length, 1, "confirming in-DOM must POST exactly once to the dispatch door");
|
|
121
|
+
assertEquals(JSON.parse(dispatched[0].body), { digest: PROPOSAL.digest }, "the dispatch POST carries the staged digest");
|
|
122
|
+
} finally {
|
|
123
|
+
h.teardown();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("#569: Cancel on the in-DOM Dispatch confirmation aborts without POSTing", async () => {
|
|
128
|
+
const h = harness();
|
|
129
|
+
try {
|
|
130
|
+
await h.flush();
|
|
131
|
+
h.click(h.host.querySelector("[data-dispatch]"));
|
|
132
|
+
await h.flush();
|
|
133
|
+
const cancelBtn = h.host.querySelector("[data-dispatch-cancel]");
|
|
134
|
+
assert(cancelBtn, "the in-DOM Dispatch confirmation must offer a Cancel affordance");
|
|
135
|
+
h.click(cancelBtn);
|
|
136
|
+
await h.flush();
|
|
137
|
+
assertEquals(posts(h.calls, DISPATCH_URL).length, 0, "cancelling must not POST to the dispatch door");
|
|
138
|
+
assert(h.host.querySelector("[data-dispatch]"), "cancelling restores the plain Dispatch button");
|
|
139
|
+
} finally {
|
|
140
|
+
h.teardown();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("#569: Dismiss discards via the in-DOM confirmation even when native window.confirm is suppressed", async () => {
|
|
145
|
+
const h = harness();
|
|
146
|
+
try {
|
|
147
|
+
await h.flush();
|
|
148
|
+
const dismissBtn = h.host.querySelector("[data-dismiss]");
|
|
149
|
+
assert(dismissBtn, "the mount must render a Dismiss button for the staged proposal");
|
|
150
|
+
h.click(dismissBtn);
|
|
151
|
+
await h.flush();
|
|
152
|
+
assertEquals(posts(h.calls, DISMISS_URL).length, 0, "Dismiss must not POST before the operator confirms in-DOM");
|
|
153
|
+
const confirmBtn = h.host.querySelector("[data-dismiss-confirm]");
|
|
154
|
+
assert(confirmBtn, "clicking Dismiss must reveal an in-DOM Confirm-dismiss control (#569)");
|
|
155
|
+
h.click(confirmBtn);
|
|
156
|
+
await h.flush();
|
|
157
|
+
const dismissed = posts(h.calls, DISMISS_URL);
|
|
158
|
+
assertEquals(dismissed.length, 1, "confirming in-DOM must POST exactly once to the dismiss door");
|
|
159
|
+
assertEquals(JSON.parse(dismissed[0].body), { digest: PROPOSAL.digest }, "the dismiss POST carries the staged digest");
|
|
160
|
+
} finally {
|
|
161
|
+
h.teardown();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("#569: Save-to-library names the entry via an in-DOM input even when native window.prompt is suppressed", async () => {
|
|
166
|
+
const h = harness();
|
|
167
|
+
try {
|
|
168
|
+
await h.flush();
|
|
169
|
+
const saveBtn = h.host.querySelector("[data-save-library]");
|
|
170
|
+
assert(saveBtn, "the mount must render a Save-to-library button for the staged proposal");
|
|
171
|
+
h.click(saveBtn);
|
|
172
|
+
await h.flush();
|
|
173
|
+
assertEquals(posts(h.calls, SAVE_URL).length, 0, "Save must not POST before the operator enters a name and confirms in-DOM");
|
|
174
|
+
const nameInput = h.host.querySelector("[data-library-name]");
|
|
175
|
+
assert(nameInput, "clicking Save-to-library must reveal an in-DOM name input (#569), not call window.prompt");
|
|
176
|
+
nameInput.value = "Reusable graph";
|
|
177
|
+
|
|
178
|
+
const saveConfirmBtn = h.host.querySelector("[data-save-library-confirm]");
|
|
179
|
+
assert(saveConfirmBtn, "the in-DOM Save-to-library control must offer a Save affordance");
|
|
180
|
+
h.click(saveConfirmBtn);
|
|
181
|
+
await h.flush();
|
|
182
|
+
const saved = posts(h.calls, SAVE_URL);
|
|
183
|
+
assertEquals(saved.length, 1, "confirming in-DOM must POST exactly once to the save-to-library door");
|
|
184
|
+
assertEquals(
|
|
185
|
+
JSON.parse(saved[0].body),
|
|
186
|
+
{ name: "Reusable graph", digest: PROPOSAL.digest },
|
|
187
|
+
"the save POST carries the in-DOM-entered name and the staged digest",
|
|
188
|
+
);
|
|
189
|
+
} finally {
|
|
190
|
+
h.teardown();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
@@ -110,10 +110,15 @@ test("#511: DI preview — the staged view wires the proposal-bpmn door and brid
|
|
|
110
110
|
assert(/params:\s*\{\s*xml:/.test(MOUNT_JS), "staged.mount.js must carry the compiled BPMN xml in the bridge message");
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
-
test("#460/#511: Dispatch is the operator's launch — posts the digest to the dispatch door, and never compiles/stages", () => {
|
|
113
|
+
test("#460/#511/#569: Dispatch is the operator's launch — posts the digest to the dispatch door, and never compiles/stages", () => {
|
|
114
114
|
assertModuleAnchored("dispatchUrl", "app/api/actions/delivery-graph/dispatch");
|
|
115
115
|
assert(/data-dispatch=/.test(MOUNT_JS), "staged.mount.js must render a per-row Dispatch affordance carrying the digest");
|
|
116
|
-
|
|
116
|
+
// #569: the confirmation must be IN-DOM (a two-step "Confirm dispatch" affordance), NOT a native
|
|
117
|
+
// window.confirm — the sandboxed console App-View iframe (no allow-modals) suppresses window.confirm
|
|
118
|
+
// (returns false), so a native-modal gate reads as "operator declined" and the button silently no-ops.
|
|
119
|
+
assert(/data-dispatch-confirm=/.test(MOUNT_JS), "Dispatch must confirm via an in-DOM two-step control (data-dispatch-confirm), not window.confirm (#569)");
|
|
120
|
+
assert(!/window\.confirm\(/.test(MOUNT_JS), "Dispatch must NOT gate on native window.confirm — it is suppressed in the sandboxed App-View iframe (#569)");
|
|
121
|
+
assert(!/window\.prompt\(/.test(MOUNT_JS), "the staged view must NOT gate on native window.prompt — it is suppressed in the sandboxed App-View iframe (#569)");
|
|
117
122
|
// Operator-only: this surface dispatches a digest that is ALREADY staged — it must not compile or
|
|
118
123
|
// stage (that is the compose view), so the #460 boundary holds and the self-approval hole stays shut.
|
|
119
124
|
assert(!/delivery-graph\/preview\b/.test(MOUNT_JS), "staged.mount.js must NOT wire the compile/stage door");
|
|
@@ -121,9 +126,19 @@ test("#460/#511: Dispatch is the operator's launch — posts the digest to the d
|
|
|
121
126
|
assert(!/approvalToken/.test(MOUNT_JS), "staged.mount.js must NOT carry the removed replayable approvalToken");
|
|
122
127
|
});
|
|
123
128
|
|
|
124
|
-
test("#520: Dismiss is the operator's discard — posts the digest to the dismiss door, behind
|
|
129
|
+
test("#520/#569: Dismiss is the operator's discard — posts the digest to the dismiss door, behind an in-DOM confirm, and launches nothing", () => {
|
|
125
130
|
assertModuleAnchored("dismissUrl", "app/api/actions/delivery-graph/dismiss");
|
|
126
131
|
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
|
-
|
|
132
|
+
// Dismiss is a one-way discard off the staged list — confirm before it drops the proposal, via the
|
|
133
|
+
// same in-DOM two-step control as Dispatch (#569), not a native window.confirm.
|
|
134
|
+
assert(/data-dismiss-confirm=/.test(MOUNT_JS), "staged.mount.js must confirm before dismissing via an in-DOM control (data-dismiss-confirm), not window.confirm (#569)");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("#523/#569: Save-to-library names the entry via an in-DOM input, not a native window.prompt", () => {
|
|
138
|
+
assertModuleAnchored("saveLibraryUrl", "app/api/actions/delivery-graph/library/save");
|
|
139
|
+
assert(/data-save-library=/.test(MOUNT_JS), "staged.mount.js must render a per-row Save-to-library affordance carrying the digest");
|
|
140
|
+
// #569: the library name must come from an in-DOM text input; window.prompt returns null under the
|
|
141
|
+
// App-View sandbox, which the old code read as "operator cancelled" → silent no-op.
|
|
142
|
+
assert(/data-library-name=/.test(MOUNT_JS), "staged.mount.js must collect the library name via an in-DOM input (data-library-name), not window.prompt (#569)");
|
|
143
|
+
assert(/data-save-library-confirm=/.test(MOUNT_JS), "staged.mount.js must confirm the save via an in-DOM Save affordance (data-save-library-confirm)");
|
|
129
144
|
});
|