@nanobpm/nano-workforce 0.184.0 → 0.184.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
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.184.1](https://github.com/nanobpm/nano-workforce/compare/v0.184.0...v0.184.1) (2026-09-08)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **delivery:** allow node repositories in staged dispatch form ([#759](https://github.com/nanobpm/nano-workforce/issues/759)) ([a9fb9f6](https://github.com/nanobpm/nano-workforce/commit/a9fb9f672a2b3cd365c191755847a303b906a343)), closes [#758](https://github.com/nanobpm/nano-workforce/issues/758)
|
|
6
|
+
|
|
1
7
|
## [0.184.0](https://github.com/nanobpm/nano-workforce/compare/v0.183.2...v0.184.0) (2026-09-07)
|
|
2
8
|
|
|
3
9
|
### Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.184.
|
|
3
|
+
"version": "0.184.1",
|
|
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",
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// • Preview DI — recompile the proposal's BPMN (with diagram interchange) and hand it UP to the host
|
|
5
5
|
// console's process explorer over the `nano-navigate` bridge, rendered read-only BEFORE dispatch;
|
|
6
6
|
// • Dispatch — the operator's launch action (#460): POST the proposal's `digest` — plus the
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// optional run-level repository fallback or explicit `repoless` opt-out — to the dispatch door.
|
|
8
|
+
// By default the graph's nodes supply their own repositories (#739/#758). Clicking Dispatch IS the approval,
|
|
9
9
|
// content-addressed to exactly the graph previewed.
|
|
10
10
|
//
|
|
11
11
|
// It REPLACES the old declarative `dataGrid` (a grid row-action can POST but cannot take the recompiled
|
|
@@ -100,22 +100,23 @@ function isPending(pending, kind, digest) {
|
|
|
100
100
|
// an in-DOM "Confirm dispatch / Cancel" pair (NOT a native window.confirm, suppressed in the sandboxed
|
|
101
101
|
// App-View iframe — #569). Clicking "Confirm dispatch" IS the operator approval (#460).
|
|
102
102
|
//
|
|
103
|
-
// The
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
// agents. A staged proposal carries no repository metadata, so the operator provides it HERE at
|
|
107
|
-
// dispatch time via the inline fields below. Ticking "checkout-less" disables the repo/base inputs.
|
|
103
|
+
// The list omits the graph's repository metadata. Default to node repositories and let the dispatch
|
|
104
|
+
// door validate provisioning (#739/#758); don't duplicate its graph-resolution algorithm here.
|
|
105
|
+
// Run-level fields are an explicit fallback, not an override of nodes' own declarations.
|
|
108
106
|
function dispatchControl(p, pending) {
|
|
109
107
|
if (isPending(pending, "dispatch", p.digest)) {
|
|
110
108
|
const repository = typeof pending.repository === "string" ? pending.repository : "";
|
|
111
109
|
const baseBranch = typeof pending.baseBranch === "string" ? pending.baseBranch : "";
|
|
112
|
-
const
|
|
113
|
-
const provDisabled =
|
|
110
|
+
const mode = pending.mode;
|
|
111
|
+
const provDisabled = mode === "fallback" ? "" : " disabled";
|
|
114
112
|
return `<span class="confirm" data-confirm="dispatch">
|
|
115
113
|
<span class="confirm-msg">${esc(DISPATCH_CONFIRM)}</span>
|
|
114
|
+
<label class="confirm-check"><input type="radio" name="dispatch-mode-${esc(p.digest)}" data-dispatch-mode="${esc(p.digest)}" value="nodes"${mode === "nodes" ? " checked" : ""} /> Use node repositories</label>
|
|
115
|
+
<label class="confirm-check"><input type="radio" name="dispatch-mode-${esc(p.digest)}" data-dispatch-mode="${esc(p.digest)}" value="fallback"${mode === "fallback" ? " checked" : ""} /> Supply run-level fallback</label>
|
|
116
|
+
<label class="confirm-check"><input type="radio" name="dispatch-mode-${esc(p.digest)}" data-dispatch-mode="${esc(p.digest)}" data-dispatch-repoless="${esc(p.digest)}" value="repoless"${mode === "repoless" ? " checked" : ""} /> Dispatch checkout-less (no repository)</label>
|
|
117
|
+
<span class="confirm-msg">Use node repositories sends no run-level repository settings; the server validates that every agent node is provisioned. A run-level repository is a fallback for nodes without their own repository, not an override of node declarations. Checkout-less explicitly opts out of repository provisioning.</span>
|
|
116
118
|
<label class="confirm-field">Repository <input class="confirm-input" type="text" data-dispatch-repository="${esc(p.digest)}" value="${esc(repository)}" placeholder="owner/repo" aria-label="Repository (owner/repo)"${provDisabled} /></label>
|
|
117
119
|
<label class="confirm-field">Base branch <input class="confirm-input" type="text" data-dispatch-base="${esc(p.digest)}" value="${esc(baseBranch)}" placeholder="main" aria-label="Base branch"${provDisabled} /></label>
|
|
118
|
-
<label class="confirm-check"><input type="checkbox" data-dispatch-repoless="${esc(p.digest)}"${repoless ? " checked" : ""} /> Dispatch checkout-less (no repository)</label>
|
|
119
120
|
<button class="btn btn-primary" type="button" data-dispatch-confirm="${esc(p.digest)}">Confirm dispatch</button>
|
|
120
121
|
<button class="btn btn-ghost" type="button" data-dispatch-cancel="${esc(p.digest)}">Cancel</button>
|
|
121
122
|
</span>`;
|
|
@@ -245,10 +246,10 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
245
246
|
// click can re-render the SAME list synchronously (without waiting for the next poll).
|
|
246
247
|
let currentProposals = [];
|
|
247
248
|
// The single open in-DOM confirmation, or null. Shape: { kind: "dispatch"|"dismiss"|"save",
|
|
248
|
-
// digest: string, name?: string, repository?: string, baseBranch?: string, repoless
|
|
249
|
+
// digest: string, name?: string, repository?: string, baseBranch?: string, mode: "nodes"|"fallback"|"repoless" }. This
|
|
249
250
|
// REPLACES the native window.confirm/window.prompt the console's sandboxed App-View iframe suppresses
|
|
250
251
|
// (#569): the operator approval is an inline two-step control (Confirm/Cancel), an inline name input
|
|
251
|
-
// (Save), or — for Dispatch — the
|
|
252
|
+
// (Save), or — for Dispatch — the provisioning mode and optional fallback fields, all rendered
|
|
252
253
|
// by renderProposal from this state so a background poll re-render preserves what the operator typed.
|
|
253
254
|
let pending = null;
|
|
254
255
|
// A re-render (renderList → new buttons) resets every button to enabled, so the disabled state is
|
|
@@ -369,22 +370,7 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
369
370
|
// the click handler, #569) — by the time we're here the operator has clicked "Confirm dispatch", so we
|
|
370
371
|
// POST the digest to the dispatch door; on success the proposal flips to `dispatched` and drops off
|
|
371
372
|
// the list on the next poll — refresh immediately so the operator sees it leave.
|
|
372
|
-
async function doDispatch(
|
|
373
|
-
const staged = typeof digest === "string" ? digest.trim() : "";
|
|
374
|
-
if (staged === "") return;
|
|
375
|
-
const options = opts && typeof opts === "object" ? opts : {};
|
|
376
|
-
const repoless = options.repoless === true;
|
|
377
|
-
const repository = typeof options.repository === "string" ? options.repository.trim() : "";
|
|
378
|
-
const baseBranch = typeof options.baseBranch === "string" ? options.baseBranch.trim() : "";
|
|
379
|
-
// The dispatch door (issue #729) requires an isolation envelope: BOTH `repository` + `baseBranch`,
|
|
380
|
-
// or an explicit `repoless: true` opt-out. Guard here so the operator gets an inline hint instead of
|
|
381
|
-
// a bare 400 from the door (the confirm has already been closed by the click handler on a valid one).
|
|
382
|
-
if (!repoless && (repository === "" || baseBranch === "")) {
|
|
383
|
-
setStatus("Provide both a repository (owner/repo) and a base branch, or tick “Dispatch checkout-less”.", "err");
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
// Mirror the door's mutual-exclusivity contract: pass EITHER the repo envelope OR `repoless`, never both.
|
|
387
|
-
const payload = repoless ? { digest: staged, repoless: true } : { digest: staged, repository, baseBranch };
|
|
373
|
+
async function doDispatch(payload) {
|
|
388
374
|
busy(true);
|
|
389
375
|
setStatus("Dispatching…");
|
|
390
376
|
try {
|
|
@@ -461,7 +447,7 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
461
447
|
function openConfirm(kind, digest, name) {
|
|
462
448
|
const staged = typeof digest === "string" ? digest.trim() : "";
|
|
463
449
|
if (staged === "") return;
|
|
464
|
-
pending = { kind, digest: staged, name: typeof name === "string" ? name : "", repository: "", baseBranch: "",
|
|
450
|
+
pending = { kind, digest: staged, name: typeof name === "string" ? name : "", repository: "", baseBranch: "", mode: "nodes" };
|
|
465
451
|
rerender();
|
|
466
452
|
// Move focus into the name input so the operator can type immediately (best-effort; not all hosts
|
|
467
453
|
// implement focus()).
|
|
@@ -496,12 +482,6 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
496
482
|
return input && typeof input.value === "string" ? input.value : "";
|
|
497
483
|
}
|
|
498
484
|
|
|
499
|
-
// Read the current state of the inline "Dispatch checkout-less" checkbox for `digest`.
|
|
500
|
-
function readRepoless(digest) {
|
|
501
|
-
const box = listEl.querySelector(`[data-dispatch-repoless="${cssAttr(digest)}"]`);
|
|
502
|
-
return !!(box && box.checked);
|
|
503
|
-
}
|
|
504
|
-
|
|
505
485
|
// Keep pending in sync as the operator types, so a background poll re-render (or a later confirm)
|
|
506
486
|
// preserves what they've entered — the library name (Save) and the repo/base envelope fields (Dispatch).
|
|
507
487
|
listEl.addEventListener("input", (ev) => {
|
|
@@ -523,14 +503,15 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
523
503
|
}
|
|
524
504
|
});
|
|
525
505
|
|
|
526
|
-
//
|
|
527
|
-
// re-render on change — first hoisting the live text values into pending so the toggle doesn't drop them.
|
|
506
|
+
// Preserve fallback text across mode changes, but only enable and submit it in fallback mode.
|
|
528
507
|
listEl.addEventListener("change", (ev) => {
|
|
529
|
-
const
|
|
530
|
-
if (
|
|
508
|
+
const choice = ev.target && ev.target.closest ? ev.target.closest("[data-dispatch-mode]") : null;
|
|
509
|
+
if (choice && choice.checked && pending != null && pending.kind === "dispatch" &&
|
|
510
|
+
choice.getAttribute("data-dispatch-mode") === pending.digest &&
|
|
511
|
+
["nodes", "fallback", "repoless"].includes(choice.value)) {
|
|
531
512
|
pending.repository = readDispatchField(pending.digest, "repository");
|
|
532
513
|
pending.baseBranch = readDispatchField(pending.digest, "base");
|
|
533
|
-
pending.
|
|
514
|
+
pending.mode = choice.value;
|
|
534
515
|
rerender();
|
|
535
516
|
}
|
|
536
517
|
});
|
|
@@ -556,21 +537,26 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
556
537
|
const dispatchConfirmBtn = closest("[data-dispatch-confirm]");
|
|
557
538
|
if (dispatchConfirmBtn) {
|
|
558
539
|
ev.preventDefault();
|
|
559
|
-
const
|
|
540
|
+
const rawDigest = dispatchConfirmBtn.getAttribute("data-dispatch-confirm");
|
|
541
|
+
const digest = typeof rawDigest === "string" ? rawDigest.trim() : "";
|
|
560
542
|
const isThisPending = pending != null && pending.kind === "dispatch" && pending.digest === digest;
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const repoless = readRepoless(digest) || (isThisPending && pending.repoless === true);
|
|
543
|
+
if (!isThisPending) return;
|
|
544
|
+
const mode = pending.mode;
|
|
564
545
|
const repository = readDispatchField(digest, "repository").trim();
|
|
565
546
|
const baseBranch = readDispatchField(digest, "base").trim();
|
|
566
547
|
// Keep the inline confirmation OPEN on an incomplete envelope so the operator can fix it in place
|
|
567
548
|
// (issue #729) — closing it would drop the fields they'd started filling.
|
|
568
|
-
if (
|
|
569
|
-
setStatus("
|
|
549
|
+
if (mode === "fallback" && (repository === "" || baseBranch === "")) {
|
|
550
|
+
setStatus("Run-level fallback requires both a repository (owner/repo) and a base branch.", "err");
|
|
570
551
|
return;
|
|
571
552
|
}
|
|
553
|
+
// Construct only the selected mode's fields: disabled fallback text must never leak into node
|
|
554
|
+
// or checkout-less dispatches, and repoless must never be inferred from blank fields.
|
|
555
|
+
const payload = mode === "fallback"
|
|
556
|
+
? { digest, repository, baseBranch }
|
|
557
|
+
: mode === "repoless" ? { digest, repoless: true } : { digest };
|
|
572
558
|
closeConfirm();
|
|
573
|
-
doDispatch(
|
|
559
|
+
doDispatch(payload);
|
|
574
560
|
return;
|
|
575
561
|
}
|
|
576
562
|
const dispatchCancelBtn = closest("[data-dispatch-cancel]");
|
|
@@ -37,7 +37,7 @@ interface FetchCall {
|
|
|
37
37
|
|
|
38
38
|
/** Boot the real mount over a linkedom DOM whose `window` mimics a sandboxed App-View iframe (native
|
|
39
39
|
* confirm/prompt SUPPRESSED → false/null), with a recording fetch double. */
|
|
40
|
-
function harness() {
|
|
40
|
+
function harness(dispatchError?: string) {
|
|
41
41
|
const { window: domWindow, document } = parseHTML(
|
|
42
42
|
"<!doctype html><html><body><div id='host'></div></body></html>",
|
|
43
43
|
);
|
|
@@ -54,7 +54,11 @@ function harness() {
|
|
|
54
54
|
if (method === "GET" && url === STAGED_URL) {
|
|
55
55
|
return new Response(JSON.stringify({ proposals: [PROPOSAL] }), { status: 200 });
|
|
56
56
|
}
|
|
57
|
-
if (url === DISPATCH_URL)
|
|
57
|
+
if (url === DISPATCH_URL) {
|
|
58
|
+
return dispatchError
|
|
59
|
+
? new Response(JSON.stringify({ ok: false, error: dispatchError }), { status: 400 })
|
|
60
|
+
: new Response(JSON.stringify({ ok: true }), { status: 202 });
|
|
61
|
+
}
|
|
58
62
|
if (url === DISMISS_URL) return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
59
63
|
if (url === SAVE_URL) return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
60
64
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
@@ -93,8 +97,14 @@ function harness() {
|
|
|
93
97
|
el.dispatchEvent(new domWindow.Event("click", { bubbles: true, cancelable: true }));
|
|
94
98
|
const fire = (el: { dispatchEvent: (ev: unknown) => boolean }, type: string) =>
|
|
95
99
|
el.dispatchEvent(new domWindow.Event(type, { bubbles: true, cancelable: true }));
|
|
100
|
+
const mode = (value: string) => {
|
|
101
|
+
const input = host.querySelector(`[data-dispatch-mode][value="${value}"]`);
|
|
102
|
+
assert(input, `the mounted confirmation must offer ${value} mode`);
|
|
103
|
+
input.checked = true;
|
|
104
|
+
fire(input, "change");
|
|
105
|
+
};
|
|
96
106
|
|
|
97
|
-
return { host, calls, teardown, flush, click, fire };
|
|
107
|
+
return { host, calls, teardown, flush, click, fire, mode };
|
|
98
108
|
}
|
|
99
109
|
|
|
100
110
|
const posts = (calls: FetchCall[], url: string) => calls.filter((c) => c.method === "POST" && c.url === url);
|
|
@@ -112,12 +122,12 @@ test("#569/#729: Dispatch dispatches via the in-DOM confirmation with the repo e
|
|
|
112
122
|
h.click(dispatchBtn);
|
|
113
123
|
await h.flush();
|
|
114
124
|
assertEquals(posts(h.calls, DISPATCH_URL).length, 0, "Dispatch must not POST before the operator confirms in-DOM");
|
|
125
|
+
h.mode("fallback");
|
|
115
126
|
const confirmBtn = h.host.querySelector("[data-dispatch-confirm]");
|
|
116
127
|
assert(confirmBtn, "clicking Dispatch must reveal an in-DOM Confirm-dispatch control (#569), not call window.confirm");
|
|
117
128
|
assert(!h.host.querySelector("[data-dispatch]"), "the plain Dispatch button is replaced by the inline confirmation while it is open");
|
|
118
129
|
|
|
119
|
-
//
|
|
120
|
-
// proposal carries no repo metadata, so the cockpit collects it here.
|
|
130
|
+
// In fallback mode the operator explicitly supplies the run-level repository-isolation envelope.
|
|
121
131
|
const repoInput = h.host.querySelector("[data-dispatch-repository]");
|
|
122
132
|
const baseInput = h.host.querySelector("[data-dispatch-base]");
|
|
123
133
|
assert(repoInput && baseInput, "the Dispatch confirmation must expose repository + base-branch fields (#729)");
|
|
@@ -165,23 +175,134 @@ test("#729: Dispatch checkout-less posts `repoless: true` (no repo envelope) ins
|
|
|
165
175
|
}
|
|
166
176
|
});
|
|
167
177
|
|
|
168
|
-
test("#729:
|
|
178
|
+
test("#729/#758: fallback mode requires both repository and base branch and keeps incomplete confirmation open", async () => {
|
|
169
179
|
const h = harness();
|
|
170
180
|
try {
|
|
171
181
|
await h.flush();
|
|
172
182
|
h.click(h.host.querySelector("[data-dispatch]"));
|
|
173
183
|
await h.flush();
|
|
174
|
-
|
|
175
|
-
//
|
|
184
|
+
h.mode("fallback");
|
|
185
|
+
// An explicitly selected fallback needs both fields; keep it editable rather than silently
|
|
186
|
+
// changing to node repositories or checkout-less when the fields are blank.
|
|
176
187
|
h.click(h.host.querySelector("[data-dispatch-confirm]"));
|
|
177
188
|
await h.flush();
|
|
178
189
|
assertEquals(posts(h.calls, DISPATCH_URL).length, 0, "an incomplete envelope must not POST to the dispatch door (#729)");
|
|
179
190
|
assert(h.host.querySelector("[data-dispatch-confirm]"), "the confirmation stays open so the operator can supply the envelope");
|
|
191
|
+
const repoInput = h.host.querySelector("[data-dispatch-repository]");
|
|
192
|
+
const baseInput = h.host.querySelector("[data-dispatch-base]");
|
|
193
|
+
for (const [repository, baseBranch] of [["acme/widgets", " "], [" ", "main"]]) {
|
|
194
|
+
repoInput.value = repository;
|
|
195
|
+
baseInput.value = baseBranch;
|
|
196
|
+
h.click(h.host.querySelector("[data-dispatch-confirm]"));
|
|
197
|
+
await h.flush();
|
|
198
|
+
assertEquals(posts(h.calls, DISPATCH_URL).length, 0, "each fallback field is required");
|
|
199
|
+
assert(h.host.querySelector("[data-dispatch-confirm]"), "invalid fallback remains editable");
|
|
200
|
+
}
|
|
180
201
|
} finally {
|
|
181
202
|
h.teardown();
|
|
182
203
|
}
|
|
183
204
|
});
|
|
184
205
|
|
|
206
|
+
test("#758: node-provisioned graph confirms through the real mount with digest only", async () => {
|
|
207
|
+
const h = harness();
|
|
208
|
+
try {
|
|
209
|
+
await h.flush();
|
|
210
|
+
h.click(h.host.querySelector("[data-dispatch]"));
|
|
211
|
+
assertEquals(posts(h.calls, DISPATCH_URL).length, 0, "opening confirmation must not dispatch");
|
|
212
|
+
h.click(h.host.querySelector("[data-dispatch-confirm]"));
|
|
213
|
+
await h.flush();
|
|
214
|
+
const dispatched = posts(h.calls, DISPATCH_URL);
|
|
215
|
+
assertEquals(dispatched.length, 1, "node repositories must reach the authoritative dispatch door");
|
|
216
|
+
assertEquals(JSON.parse(dispatched[0].body), { digest: PROPOSAL.digest });
|
|
217
|
+
assert(h.host.querySelector("#dg-staged-status").textContent.includes("Dispatched"));
|
|
218
|
+
} finally {
|
|
219
|
+
h.teardown();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("dispatch normalizes the attribute-sourced digest before POSTing (untrusted DOM value)", async () => {
|
|
224
|
+
// The confirm digest is read back from a DOM attribute (untrusted) at confirm time; like doDismiss/
|
|
225
|
+
// doSaveToLibrary/doPreviewDi, dispatch must trim it so accidental whitespace never reaches the door.
|
|
226
|
+
const h = harness();
|
|
227
|
+
try {
|
|
228
|
+
await h.flush();
|
|
229
|
+
h.click(h.host.querySelector("[data-dispatch]"));
|
|
230
|
+
const confirmBtn = h.host.querySelector("[data-dispatch-confirm]");
|
|
231
|
+
assert(confirmBtn, "the in-DOM Confirm dispatch affordance must appear");
|
|
232
|
+
// Simulate an untrusted DOM value: pad the attribute the confirm handler reads the digest from.
|
|
233
|
+
confirmBtn.setAttribute("data-dispatch-confirm", ` ${PROPOSAL.digest} `);
|
|
234
|
+
h.click(confirmBtn);
|
|
235
|
+
await h.flush();
|
|
236
|
+
const dispatched = posts(h.calls, DISPATCH_URL);
|
|
237
|
+
assertEquals(dispatched.length, 1, "the confirmed dispatch must POST despite the padded attribute");
|
|
238
|
+
assertEquals(JSON.parse(dispatched[0].body), { digest: PROPOSAL.digest }, "the digest is trimmed before dispatch");
|
|
239
|
+
} finally {
|
|
240
|
+
h.teardown();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("#758: node mode displays the server's missing-node provisioning error without inferring checkout-less", async () => {
|
|
245
|
+
const error = "1 agent node(s) resolve to no repository (implement-api): each must declare its own `repository`, or the dispatch must supply a run-level `repository` + `baseBranch` fallback";
|
|
246
|
+
const h = harness(error);
|
|
247
|
+
try {
|
|
248
|
+
await h.flush();
|
|
249
|
+
h.click(h.host.querySelector("[data-dispatch]"));
|
|
250
|
+
h.click(h.host.querySelector("[data-dispatch-confirm]"));
|
|
251
|
+
await h.flush();
|
|
252
|
+
assertEquals(h.host.querySelector("#dg-staged-status").textContent, error);
|
|
253
|
+
assertEquals(posts(h.calls, DISPATCH_URL).map((call) => JSON.parse(call.body)), [{ digest: PROPOSAL.digest }]);
|
|
254
|
+
assert(h.host.querySelector("[data-dispatch]"), "the rejected proposal remains available");
|
|
255
|
+
} finally {
|
|
256
|
+
h.teardown();
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("#758: repository choices explain server validation and run-level fallback semantics", async () => {
|
|
261
|
+
const h = harness();
|
|
262
|
+
try {
|
|
263
|
+
await h.flush();
|
|
264
|
+
h.click(h.host.querySelector("[data-dispatch]"));
|
|
265
|
+
const nodes = h.host.querySelector('[data-dispatch-mode][value="nodes"]');
|
|
266
|
+
assert(nodes?.hasAttribute("checked"), "node repositories is the explicit default, not checkout-less");
|
|
267
|
+
assert(h.host.querySelector("[data-dispatch-repository]").disabled);
|
|
268
|
+
assert(h.host.querySelector("[data-dispatch-base]").disabled);
|
|
269
|
+
const text = h.host.textContent;
|
|
270
|
+
assert(text.includes("Use node repositories"));
|
|
271
|
+
assert(text.includes("fallback for nodes without their own repository"));
|
|
272
|
+
assert(text.includes("server validates"));
|
|
273
|
+
} finally {
|
|
274
|
+
h.teardown();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
for (const target of ["nodes", "repoless", "fallback"]) {
|
|
279
|
+
test(`#758: switching modes to ${target} submits only the selected provisioning choice`, async () => {
|
|
280
|
+
const h = harness();
|
|
281
|
+
try {
|
|
282
|
+
await h.flush();
|
|
283
|
+
h.click(h.host.querySelector("[data-dispatch]"));
|
|
284
|
+
h.mode("fallback");
|
|
285
|
+
h.host.querySelector("[data-dispatch-repository]").value = " acme/widgets ";
|
|
286
|
+
h.host.querySelector("[data-dispatch-base]").value = " main ";
|
|
287
|
+
h.mode("repoless");
|
|
288
|
+
assert(h.host.querySelector("[data-dispatch-repository]").disabled);
|
|
289
|
+
assert(h.host.querySelector("[data-dispatch-base]").disabled);
|
|
290
|
+
h.mode("nodes");
|
|
291
|
+
h.mode(target);
|
|
292
|
+
h.click(h.host.querySelector("[data-dispatch-confirm]"));
|
|
293
|
+
await h.flush();
|
|
294
|
+
const expected = target === "fallback"
|
|
295
|
+
? { digest: PROPOSAL.digest, repository: "acme/widgets", baseBranch: "main" }
|
|
296
|
+
: target === "repoless"
|
|
297
|
+
? { digest: PROPOSAL.digest, repoless: true }
|
|
298
|
+
: { digest: PROPOSAL.digest };
|
|
299
|
+
assertEquals(posts(h.calls, DISPATCH_URL).map((call) => JSON.parse(call.body)), [expected]);
|
|
300
|
+
} finally {
|
|
301
|
+
h.teardown();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
185
306
|
test("#569: Cancel on the in-DOM Dispatch confirmation aborts without POSTing", async () => {
|
|
186
307
|
const h = harness();
|
|
187
308
|
try {
|