@nanobpm/nano-workforce 0.167.0 → 0.167.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.
@@ -13,6 +13,9 @@
13
13
  <nano:extend name="featureKey" type="string" />
14
14
  <nano:extend name="question" type="string" optional="true" />
15
15
  </nano:shape>
16
+ <nano:shape id="RecordFeatureImplementingIn" name="Record feature implementing — input">
17
+ <nano:extend name="featureKey" type="string" />
18
+ </nano:shape>
16
19
  <nano:shape id="ConvergeFeatureIn" name="Converge feature PR — input">
17
20
  <nano:extend name="featureKey" type="string" />
18
21
  <nano:extend name="prKey" type="string" optional="true" />
@@ -183,9 +186,19 @@
183
186
  <zeebe:output source="=if (is defined(transcriptUrl)) then transcriptUrl else null" target="transcriptUrl" />
184
187
  </zeebe:ioMapping>
185
188
  </bpmn:extensionElements>
189
+ <bpmn:incoming>f_implementing</bpmn:incoming>
190
+ <bpmn:outgoing>w_toGw</bpmn:outgoing>
191
+ </bpmn:serviceTask>
192
+ <bpmn:serviceTask id="record-feature-implementing" name="Record implementing">
193
+ <bpmn:extensionElements>
194
+ <zeebe:taskDefinition type="pr.record-feature-implementing" />
195
+ <zeebe:properties>
196
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="RecordFeatureImplementingIn" />
197
+ </zeebe:properties>
198
+ </bpmn:extensionElements>
186
199
  <bpmn:incoming>f_toImplement</bpmn:incoming>
187
200
  <bpmn:incoming>w_answerLoop</bpmn:incoming>
188
- <bpmn:outgoing>w_toGw</bpmn:outgoing>
201
+ <bpmn:outgoing>f_implementing</bpmn:outgoing>
189
202
  </bpmn:serviceTask>
190
203
  <bpmn:exclusiveGateway id="w_gw" name="escalated?" default="w_done">
191
204
  <bpmn:incoming>w_toGw</bpmn:incoming>
@@ -287,7 +300,8 @@
287
300
  </bpmn:sequenceFlow>
288
301
  <bpmn:sequenceFlow id="f_readiness_skip" name="no gate" sourceRef="gw-readiness" targetRef="ensure-base-branch" />
289
302
  <bpmn:sequenceFlow id="f_readiness_done" sourceRef="readiness-preflight" targetRef="ensure-base-branch" />
290
- <bpmn:sequenceFlow id="f_toImplement" sourceRef="ensure-base-branch" targetRef="implement-task" />
303
+ <bpmn:sequenceFlow id="f_toImplement" sourceRef="ensure-base-branch" targetRef="record-feature-implementing" />
304
+ <bpmn:sequenceFlow id="f_implementing" sourceRef="record-feature-implementing" targetRef="implement-task" />
291
305
  <bpmn:sequenceFlow id="w_toGw" sourceRef="implement-task" targetRef="w_gw" />
292
306
  <bpmn:sequenceFlow id="w_escalate" name="escalated" sourceRef="w_gw" targetRef="record-feature-escalation">
293
307
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "escalated" and question != null and trim(question) != ""</bpmn:conditionExpression>
@@ -295,7 +309,7 @@
295
309
  <bpmn:sequenceFlow id="w_toEscalationTask" sourceRef="record-feature-escalation" targetRef="feature-escalation" />
296
310
  <bpmn:sequenceFlow id="w_done" name="done" sourceRef="w_gw" targetRef="record-feature" />
297
311
  <bpmn:sequenceFlow id="w_toAnswerGw" sourceRef="feature-escalation" targetRef="w_gw_answer" />
298
- <bpmn:sequenceFlow id="w_answerLoop" name="answer" sourceRef="w_gw_answer" targetRef="implement-task">
312
+ <bpmn:sequenceFlow id="w_answerLoop" name="answer" sourceRef="w_gw_answer" targetRef="record-feature-implementing">
299
313
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=resolution = "answer"</bpmn:conditionExpression>
300
314
  </bpmn:sequenceFlow>
301
315
  <bpmn:sequenceFlow id="w_abandon" name="abandon" sourceRef="w_gw_answer" targetRef="record-feature" />
@@ -334,60 +348,63 @@
334
348
  <dc:Bounds x="566" y="80" width="100" height="80" />
335
349
  </bpmndi:BPMNShape>
336
350
  <bpmndi:BPMNShape id="BPMNShape_implement-task" bpmnElement="implement-task">
351
+ <dc:Bounds x="966" y="80" width="100" height="80" />
352
+ </bpmndi:BPMNShape>
353
+ <bpmndi:BPMNShape id="BPMNShape_record-feature-implementing" bpmnElement="record-feature-implementing">
337
354
  <dc:Bounds x="766" y="80" width="100" height="80" />
338
355
  </bpmndi:BPMNShape>
339
356
  <bpmndi:BPMNShape id="BPMNShape_w_gw" bpmnElement="w_gw" isMarkerVisible="true">
340
- <dc:Bounds x="966" y="95" width="50" height="50" />
357
+ <dc:Bounds x="1166" y="95" width="50" height="50" />
341
358
  <bpmndi:BPMNLabel>
342
- <dc:Bounds x="954" y="76" width="74" height="14" />
359
+ <dc:Bounds x="1154" y="76" width="74" height="14" />
343
360
  </bpmndi:BPMNLabel>
344
361
  </bpmndi:BPMNShape>
345
362
  <bpmndi:BPMNShape id="BPMNShape_record-feature-escalation" bpmnElement="record-feature-escalation">
346
- <dc:Bounds x="1116" y="240" width="100" height="80" />
363
+ <dc:Bounds x="1316" y="240" width="100" height="80" />
347
364
  </bpmndi:BPMNShape>
348
365
  <bpmndi:BPMNShape id="BPMNShape_feature-escalation" bpmnElement="feature-escalation">
349
- <dc:Bounds x="1316" y="240" width="100" height="80" />
366
+ <dc:Bounds x="1516" y="240" width="100" height="80" />
350
367
  </bpmndi:BPMNShape>
351
368
  <bpmndi:BPMNShape id="BPMNShape_w_gw_answer" bpmnElement="w_gw_answer" isMarkerVisible="true">
352
- <dc:Bounds x="1516" y="255" width="50" height="50" />
369
+ <dc:Bounds x="1716" y="255" width="50" height="50" />
353
370
  <bpmndi:BPMNLabel>
354
- <dc:Bounds x="1513" y="236" width="56" height="14" />
371
+ <dc:Bounds x="1713" y="236" width="56" height="14" />
355
372
  </bpmndi:BPMNLabel>
356
373
  </bpmndi:BPMNShape>
357
374
  <bpmndi:BPMNShape id="BPMNShape_record-feature" bpmnElement="record-feature">
358
- <dc:Bounds x="1666" y="80" width="100" height="80" />
375
+ <dc:Bounds x="1866" y="80" width="100" height="80" />
359
376
  </bpmndi:BPMNShape>
360
377
  <bpmndi:BPMNShape id="BPMNShape_gw-blocked" bpmnElement="gw-blocked" isMarkerVisible="true">
361
- <dc:Bounds x="1866" y="95" width="50" height="50" />
378
+ <dc:Bounds x="2066" y="95" width="50" height="50" />
362
379
  <bpmndi:BPMNLabel>
363
- <dc:Bounds x="1861" y="76" width="60" height="14" />
380
+ <dc:Bounds x="2061" y="76" width="60" height="14" />
364
381
  </bpmndi:BPMNLabel>
365
382
  </bpmndi:BPMNShape>
366
383
  <bpmndi:BPMNShape id="BPMNShape_feature-blocked" bpmnElement="feature-blocked">
367
- <dc:Bounds x="2016" y="400" width="100" height="80" />
384
+ <dc:Bounds x="2216" y="400" width="100" height="80" />
368
385
  </bpmndi:BPMNShape>
369
386
  <bpmndi:BPMNShape id="BPMNShape_record-blocked-ack" bpmnElement="record-blocked-ack">
370
- <dc:Bounds x="2216" y="400" width="100" height="80" />
387
+ <dc:Bounds x="2416" y="400" width="100" height="80" />
371
388
  </bpmndi:BPMNShape>
372
389
  <bpmndi:BPMNShape id="BPMNShape_gw-converge" bpmnElement="gw-converge" isMarkerVisible="true">
373
- <dc:Bounds x="2041" y="95" width="50" height="50" />
390
+ <dc:Bounds x="2241" y="95" width="50" height="50" />
374
391
  <bpmndi:BPMNLabel>
375
- <dc:Bounds x="2033" y="76" width="67" height="14" />
392
+ <dc:Bounds x="2233" y="76" width="67" height="14" />
376
393
  </bpmndi:BPMNLabel>
377
394
  </bpmndi:BPMNShape>
378
395
  <bpmndi:BPMNShape id="BPMNShape_converge" bpmnElement="converge">
379
- <dc:Bounds x="2216" y="240" width="100" height="80" />
396
+ <dc:Bounds x="2416" y="240" width="100" height="80" />
380
397
  </bpmndi:BPMNShape>
381
398
  <bpmndi:BPMNShape id="BPMNShape_End" bpmnElement="End">
382
- <dc:Bounds x="2416" y="102" width="36" height="36" />
399
+ <dc:Bounds x="2616" y="102" width="36" height="36" />
383
400
  <bpmndi:BPMNLabel>
384
- <dc:Bounds x="2417" y="83" width="34" height="14" />
401
+ <dc:Bounds x="2617" y="83" width="34" height="14" />
385
402
  </bpmndi:BPMNLabel>
386
403
  </bpmndi:BPMNShape>
387
404
  <bpmndi:BPMNShape id="BPMNShape_be_feature_sla" bpmnElement="be_feature_sla">
388
- <dc:Bounds x="1348" y="302" width="36" height="36" />
405
+ <dc:Bounds x="1548" y="302" width="36" height="36" />
389
406
  <bpmndi:BPMNLabel>
390
- <dc:Bounds x="1324" y="383" width="84" height="14" />
407
+ <dc:Bounds x="1524" y="383" width="84" height="14" />
391
408
  </bpmndi:BPMNLabel>
392
409
  </bpmndi:BPMNShape>
393
410
  <bpmndi:BPMNEdge id="BPMNEdge_f_toReadinessGw" bpmnElement="f_toReadinessGw">
@@ -405,41 +422,45 @@
405
422
  <di:waypoint x="666" y="120" />
406
423
  <di:waypoint x="766" y="120" />
407
424
  </bpmndi:BPMNEdge>
408
- <bpmndi:BPMNEdge id="BPMNEdge_w_toGw" bpmnElement="w_toGw">
425
+ <bpmndi:BPMNEdge id="BPMNEdge_f_implementing" bpmnElement="f_implementing">
409
426
  <di:waypoint x="866" y="120" />
410
427
  <di:waypoint x="966" y="120" />
411
428
  </bpmndi:BPMNEdge>
429
+ <bpmndi:BPMNEdge id="BPMNEdge_w_toGw" bpmnElement="w_toGw">
430
+ <di:waypoint x="1066" y="120" />
431
+ <di:waypoint x="1166" y="120" />
432
+ </bpmndi:BPMNEdge>
412
433
  <bpmndi:BPMNEdge id="BPMNEdge_w_done" bpmnElement="w_done">
413
- <di:waypoint x="1016" y="120" />
414
- <di:waypoint x="1666" y="120" />
434
+ <di:waypoint x="1216" y="120" />
435
+ <di:waypoint x="1866" y="120" />
415
436
  <bpmndi:BPMNLabel>
416
- <dc:Bounds x="1325" y="98" width="32" height="14" />
437
+ <dc:Bounds x="1525" y="98" width="32" height="14" />
417
438
  </bpmndi:BPMNLabel>
418
439
  </bpmndi:BPMNEdge>
419
440
  <bpmndi:BPMNEdge id="BPMNEdge_f_toBlockedGw" bpmnElement="f_toBlockedGw">
420
- <di:waypoint x="1766" y="120" />
421
- <di:waypoint x="1866" y="120" />
441
+ <di:waypoint x="1966" y="120" />
442
+ <di:waypoint x="2066" y="120" />
422
443
  </bpmndi:BPMNEdge>
423
444
  <bpmndi:BPMNEdge id="BPMNEdge_f_notBlocked" bpmnElement="f_notBlocked">
424
- <di:waypoint x="1916" y="120" />
425
- <di:waypoint x="2041" y="120" />
445
+ <di:waypoint x="2116" y="120" />
446
+ <di:waypoint x="2241" y="120" />
426
447
  <bpmndi:BPMNLabel>
427
- <dc:Bounds x="1940" y="98" width="78" height="14" />
448
+ <dc:Bounds x="2140" y="98" width="78" height="14" />
428
449
  </bpmndi:BPMNLabel>
429
450
  </bpmndi:BPMNEdge>
430
451
  <bpmndi:BPMNEdge id="BPMNEdge_f_noConverge" bpmnElement="f_noConverge">
431
- <di:waypoint x="2091" y="120" />
432
- <di:waypoint x="2416" y="120" />
452
+ <di:waypoint x="2291" y="120" />
453
+ <di:waypoint x="2616" y="120" />
433
454
  <bpmndi:BPMNLabel>
434
- <dc:Bounds x="2218" y="98" width="71" height="14" />
455
+ <dc:Bounds x="2418" y="98" width="71" height="14" />
435
456
  </bpmndi:BPMNLabel>
436
457
  </bpmndi:BPMNEdge>
437
458
  <bpmndi:BPMNEdge id="BPMNEdge_w_abandon" bpmnElement="w_abandon">
438
- <di:waypoint x="1566" y="280" />
439
- <di:waypoint x="1716" y="280" />
440
- <di:waypoint x="1716" y="160" />
459
+ <di:waypoint x="1766" y="280" />
460
+ <di:waypoint x="1916" y="280" />
461
+ <di:waypoint x="1916" y="160" />
441
462
  <bpmndi:BPMNLabel>
442
- <dc:Bounds x="1615" y="258" width="53" height="14" />
463
+ <dc:Bounds x="1815" y="258" width="53" height="14" />
443
464
  </bpmndi:BPMNLabel>
444
465
  </bpmndi:BPMNEdge>
445
466
  <bpmndi:BPMNEdge id="BPMNEdge_f_readiness_gate" bpmnElement="f_readiness_gate">
@@ -451,27 +472,27 @@
451
472
  </bpmndi:BPMNLabel>
452
473
  </bpmndi:BPMNEdge>
453
474
  <bpmndi:BPMNEdge id="BPMNEdge_w_escalate" bpmnElement="w_escalate">
454
- <di:waypoint x="991" y="145" />
455
- <di:waypoint x="991" y="280" />
456
- <di:waypoint x="1116" y="280" />
475
+ <di:waypoint x="1191" y="145" />
476
+ <di:waypoint x="1191" y="280" />
477
+ <di:waypoint x="1316" y="280" />
457
478
  <bpmndi:BPMNLabel>
458
- <dc:Bounds x="996" y="206" width="67" height="14" />
479
+ <dc:Bounds x="1196" y="206" width="67" height="14" />
459
480
  </bpmndi:BPMNLabel>
460
481
  </bpmndi:BPMNEdge>
461
482
  <bpmndi:BPMNEdge id="BPMNEdge_f_blocked" bpmnElement="f_blocked">
462
- <di:waypoint x="1891" y="145" />
463
- <di:waypoint x="1891" y="440" />
464
- <di:waypoint x="2016" y="440" />
483
+ <di:waypoint x="2091" y="145" />
484
+ <di:waypoint x="2091" y="440" />
485
+ <di:waypoint x="2216" y="440" />
465
486
  <bpmndi:BPMNLabel>
466
- <dc:Bounds x="1896" y="286" width="53" height="14" />
487
+ <dc:Bounds x="2096" y="286" width="53" height="14" />
467
488
  </bpmndi:BPMNLabel>
468
489
  </bpmndi:BPMNEdge>
469
490
  <bpmndi:BPMNEdge id="BPMNEdge_f_converge" bpmnElement="f_converge">
470
- <di:waypoint x="2066" y="145" />
471
- <di:waypoint x="2066" y="280" />
472
- <di:waypoint x="2216" y="280" />
491
+ <di:waypoint x="2266" y="145" />
492
+ <di:waypoint x="2266" y="280" />
493
+ <di:waypoint x="2416" y="280" />
473
494
  <bpmndi:BPMNLabel>
474
- <dc:Bounds x="2071" y="206" width="60" height="14" />
495
+ <dc:Bounds x="2271" y="206" width="60" height="14" />
475
496
  </bpmndi:BPMNLabel>
476
497
  </bpmndi:BPMNEdge>
477
498
  <bpmndi:BPMNEdge id="BPMNEdge_f_readiness_done" bpmnElement="f_readiness_done">
@@ -480,43 +501,43 @@
480
501
  <di:waypoint x="616" y="160" />
481
502
  </bpmndi:BPMNEdge>
482
503
  <bpmndi:BPMNEdge id="BPMNEdge_w_toEscalationTask" bpmnElement="w_toEscalationTask">
483
- <di:waypoint x="1216" y="280" />
484
- <di:waypoint x="1316" y="280" />
485
- </bpmndi:BPMNEdge>
486
- <bpmndi:BPMNEdge id="BPMNEdge_w_toAnswerGw" bpmnElement="w_toAnswerGw">
487
504
  <di:waypoint x="1416" y="280" />
488
505
  <di:waypoint x="1516" y="280" />
489
506
  </bpmndi:BPMNEdge>
507
+ <bpmndi:BPMNEdge id="BPMNEdge_w_toAnswerGw" bpmnElement="w_toAnswerGw">
508
+ <di:waypoint x="1616" y="280" />
509
+ <di:waypoint x="1716" y="280" />
510
+ </bpmndi:BPMNEdge>
490
511
  <bpmndi:BPMNEdge id="BPMNEdge_f_toBlockedAck" bpmnElement="f_toBlockedAck">
491
- <di:waypoint x="2116" y="440" />
492
- <di:waypoint x="2216" y="440" />
512
+ <di:waypoint x="2316" y="440" />
513
+ <di:waypoint x="2416" y="440" />
493
514
  </bpmndi:BPMNEdge>
494
515
  <bpmndi:BPMNEdge id="BPMNEdge_f_blockedEnd" bpmnElement="f_blockedEnd">
495
- <di:waypoint x="2316" y="440" />
496
- <di:waypoint x="2434" y="440" />
497
- <di:waypoint x="2434" y="138" />
516
+ <di:waypoint x="2516" y="440" />
517
+ <di:waypoint x="2634" y="440" />
518
+ <di:waypoint x="2634" y="138" />
498
519
  </bpmndi:BPMNEdge>
499
520
  <bpmndi:BPMNEdge id="BPMNEdge_f_toEndConverged" bpmnElement="f_toEndConverged">
500
- <di:waypoint x="2316" y="280" />
501
- <di:waypoint x="2434" y="280" />
502
- <di:waypoint x="2434" y="138" />
521
+ <di:waypoint x="2516" y="280" />
522
+ <di:waypoint x="2634" y="280" />
523
+ <di:waypoint x="2634" y="138" />
503
524
  </bpmndi:BPMNEdge>
504
525
  <bpmndi:BPMNEdge id="BPMNEdge_w_sla" bpmnElement="w_sla">
505
- <di:waypoint x="1366" y="338" />
506
- <di:waypoint x="1366" y="358" />
507
- <di:waypoint x="1716" y="358" />
508
- <di:waypoint x="1716" y="160" />
526
+ <di:waypoint x="1566" y="338" />
527
+ <di:waypoint x="1566" y="358" />
528
+ <di:waypoint x="1916" y="358" />
529
+ <di:waypoint x="1916" y="160" />
509
530
  <bpmndi:BPMNLabel>
510
- <dc:Bounds x="1557" y="325" width="88" height="28" />
531
+ <dc:Bounds x="1757" y="325" width="88" height="28" />
511
532
  </bpmndi:BPMNLabel>
512
533
  </bpmndi:BPMNEdge>
513
534
  <bpmndi:BPMNEdge id="BPMNEdge_w_answerLoop" bpmnElement="w_answerLoop">
514
- <di:waypoint x="1541" y="305" />
515
- <di:waypoint x="1541" y="360" />
535
+ <di:waypoint x="1741" y="305" />
536
+ <di:waypoint x="1741" y="360" />
516
537
  <di:waypoint x="816" y="360" />
517
538
  <di:waypoint x="816" y="160" />
518
539
  <bpmndi:BPMNLabel>
519
- <dc:Bounds x="1154" y="338" width="49" height="14" />
540
+ <dc:Bounds x="1254" y="338" width="49" height="14" />
520
541
  </bpmndi:BPMNLabel>
521
542
  </bpmndi:BPMNEdge>
522
543
  </bpmndi:BPMNPlane>
@@ -0,0 +1,280 @@
1
+ // Red/green behavioural guard for the delivery-graph Preview & Reuse "honest toast" fix (issue #645).
2
+ //
3
+ // The DEFECT: both cross-frame producers printed an UNCONDITIONAL "✓ …" success toast synchronously
4
+ // right after `postMessage`, even though the message can be dropped by the host relay boundary — so the
5
+ // UI claimed success while nothing happened. The FIX makes the success EARNED, not assumed:
6
+ // • Library "Reuse" (library.mount.js `doReuse`) posts the compose-fill UP to the host (relayed across
7
+ // to the compose sibling App-View by nano-ide #518), shows a NEUTRAL in-progress status, and only
8
+ // renders "✓ Loaded…" when the compose mount acks the fill (matching a correlation token) — a clear
9
+ // "Couldn't reach the composer" on a short timeout.
10
+ // • Compose "Preview generated DI" (mount.js `doPreviewDi`) posts `nano-navigate` UP to the console,
11
+ // shows a NEUTRAL in-progress status, and only renders "✓ Opened…" on a host `nano-navigate-ack` —
12
+ // "Couldn't reach the console explorer" on a short timeout.
13
+ //
14
+ // These tests drive the REAL mounts over a linkedom DOM inside a fake EMBEDDED App-View window whose
15
+ // `parent.postMessage` is recorded and whose `message` listeners can be fed synthetic acks. They fail
16
+ // against the pre-fix mounts (which render "✓" synchronously with no ack) and pass after.
17
+ import { test } from "node:test";
18
+ import { assert, assertEquals } from "#test-assert";
19
+ import { parseHTML } from "linkedom";
20
+ import { mountDeliveryGraphs, DG_COMPOSE_FILL_ACK_MESSAGE, NANO_NAVIGATE_ACK_MESSAGE } from "../pages/delivery-graphs/mount.js";
21
+ import { mountDeliveryGraphLibrary } from "../pages/delivery-graphs/library.mount.js";
22
+
23
+ const ORIGIN = "https://app.test";
24
+ const CHECK = "\u2713"; // the "✓" a success toast must EARN, never assume
25
+
26
+ interface PostedMessage {
27
+ data: { type?: string; graphJson?: string; token?: string | null; target?: string; params?: unknown };
28
+ targetOrigin: string;
29
+ }
30
+
31
+ /** Boot a mount inside a fake EMBEDDED App-View window (parent !== window) with a recording
32
+ * parent.postMessage and a capturable `message` listener set, over a linkedom DOM. */
33
+ function harness(
34
+ mountFn: (host: unknown, config: Record<string, unknown>) => () => void,
35
+ config: Record<string, unknown>,
36
+ fetchImpl?: (input: unknown, init?: unknown) => Promise<Response>,
37
+ ) {
38
+ const { window: domWindow, document } = parseHTML("<!doctype html><html><body><div id='host'></div></body></html>");
39
+ const host = document.getElementById("host");
40
+ assert(host, "harness host element must exist");
41
+
42
+ const posted: PostedMessage[] = [];
43
+ const messageListeners: Array<(ev: unknown) => void> = [];
44
+ const parentWindow = {
45
+ postMessage: (data: PostedMessage["data"], targetOrigin: string) => {
46
+ posted.push({ data, targetOrigin });
47
+ },
48
+ };
49
+ const fakeWindow = {
50
+ location: { origin: ORIGIN, href: `${ORIGIN}/delivery-graphs/` },
51
+ parent: parentWindow, // parent !== fakeWindow ⇒ embedded
52
+ addEventListener: (type: string, fn: (ev: unknown) => void) => {
53
+ if (type === "message") messageListeners.push(fn);
54
+ },
55
+ removeEventListener: (type: string, fn: (ev: unknown) => void) => {
56
+ if (type !== "message") return;
57
+ const i = messageListeners.indexOf(fn);
58
+ if (i >= 0) messageListeners.splice(i, 1);
59
+ },
60
+ };
61
+
62
+ const origFetch = globalThis.fetch;
63
+ if (fetchImpl) globalThis.fetch = fetchImpl as typeof fetch;
64
+ const origWindow = Reflect.get(globalThis, "window");
65
+ Reflect.set(globalThis, "window", fakeWindow);
66
+
67
+ // Restore the mutated globals if the mount itself throws, so a failing mount can't
68
+ // leak `fetch`/`window` into later tests and make them fail in confusing ways.
69
+ let dispose: () => void;
70
+ try {
71
+ dispose = mountFn(host, config);
72
+ } catch (err) {
73
+ if (fetchImpl) globalThis.fetch = origFetch;
74
+ Reflect.set(globalThis, "window", origWindow);
75
+ throw err;
76
+ }
77
+
78
+ const teardown = () => {
79
+ dispose();
80
+ if (fetchImpl) globalThis.fetch = origFetch;
81
+ Reflect.set(globalThis, "window", origWindow);
82
+ };
83
+ const flush = async () => {
84
+ for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0));
85
+ };
86
+ const click = (el: { dispatchEvent: (ev: unknown) => boolean } | null) => {
87
+ assert(el, "click target must exist");
88
+ el!.dispatchEvent(new domWindow.Event("click", { bubbles: true, cancelable: true }));
89
+ };
90
+ // Deliver a synthetic same-origin message from the parent (the console relay) to the mount's listeners.
91
+ const emitFromParent = (data: unknown) => {
92
+ for (const fn of [...messageListeners]) fn({ origin: ORIGIN, source: parentWindow, data });
93
+ };
94
+ const status = () => host!.querySelector(".status") as { textContent: string | null; className: string } | null;
95
+
96
+ return { host, posted, teardown, flush, click, emitFromParent, status };
97
+ }
98
+
99
+ const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
100
+
101
+ // ── Library "Reuse" ────────────────────────────────────────────────────────────────────────────────
102
+ const LIBRARY_URL = `${ORIGIN}/app/api/delivery-graph/library`;
103
+ const ENTRY = { id: "lib-1", name: "Onboarding", graph: '{"nodes":[],"edges":[]}' };
104
+ const libraryFetch = async (input: unknown) => {
105
+ if (String(input).startsWith(LIBRARY_URL)) return new Response(JSON.stringify({ entries: [ENTRY] }), { status: 200 });
106
+ return new Response(JSON.stringify({}), { status: 404 });
107
+ };
108
+
109
+ function libraryHarness() {
110
+ return harness(
111
+ mountDeliveryGraphLibrary as never,
112
+ { libraryUrl: LIBRARY_URL, refreshMs: 1_000_000_000, ackTimeoutMs: 40 },
113
+ libraryFetch,
114
+ );
115
+ }
116
+
117
+ test("#645: Reuse shows NO optimistic ✓ toast — a neutral in-progress status until the compose acks", async () => {
118
+ const h = libraryHarness();
119
+ try {
120
+ await h.flush();
121
+ h.click(h.host!.querySelector("[data-reuse]"));
122
+ // Synchronously after the post: the fill message went UP to the host, but there is NO success yet.
123
+ const fill = h.posted.find((p) => p.data.type === "nano-delivery-graph-compose-fill");
124
+ assert(fill, "Reuse must post the compose-fill message up to the host");
125
+ assertEquals(fill!.data.graphJson, ENTRY.graph, "the fill carries the saved graph JSON");
126
+ assert(typeof fill!.data.token === "string" && fill!.data.token, "the fill carries a correlation token for the ack");
127
+ const s = h.status();
128
+ assert(s && !s.textContent!.includes(CHECK), "Reuse must NOT render a ✓ success toast synchronously (the #645 defect)");
129
+ assert(s && !/status-ok/.test(s.className), "the pre-ack Reuse status must not carry the success (ok) tone");
130
+ } finally {
131
+ h.teardown();
132
+ }
133
+ });
134
+
135
+ test("#645: Reuse renders ✓ Loaded only once the compose acks the matching fill token", async () => {
136
+ const h = libraryHarness();
137
+ try {
138
+ await h.flush();
139
+ h.click(h.host!.querySelector("[data-reuse]"));
140
+ const fill = h.posted.find((p) => p.data.type === "nano-delivery-graph-compose-fill");
141
+ assert(fill, "Reuse must post the compose-fill message");
142
+ // A stale/mismatched-token ack must NOT complete this Reuse.
143
+ h.emitFromParent({ type: DG_COMPOSE_FILL_ACK_MESSAGE, token: "some-other-token" });
144
+ assert(!h.status()!.textContent!.includes(CHECK), "an ack for a different token must not satisfy this Reuse");
145
+ // The real ack (echoing our token) earns the success toast.
146
+ h.emitFromParent({ type: DG_COMPOSE_FILL_ACK_MESSAGE, token: fill!.data.token });
147
+ const s = h.status();
148
+ assert(s!.textContent!.includes(CHECK) && /Loaded/.test(s!.textContent!), "on ack Reuse renders the ✓ Loaded toast");
149
+ assert(/status-ok/.test(s!.className), "the acked Reuse status carries the success tone");
150
+ } finally {
151
+ h.teardown();
152
+ }
153
+ });
154
+
155
+ test("#645: Reuse ignores an ack with a null/absent token — it can't be correlated, so no ✓", async () => {
156
+ const h = libraryHarness();
157
+ try {
158
+ await h.flush();
159
+ h.click(h.host!.querySelector("[data-reuse]"));
160
+ const fill = h.posted.find((p) => p.data.type === "nano-delivery-graph-compose-fill");
161
+ assert(fill, "Reuse must post the compose-fill message");
162
+ // The compose mount emits `token: null` when a fill arrives without a token; such an ack can't be
163
+ // correlated to this Reuse and must NOT satisfy it (else the #645 false-positive toast returns).
164
+ h.emitFromParent({ type: DG_COMPOSE_FILL_ACK_MESSAGE, token: null });
165
+ assert(!h.status()!.textContent!.includes(CHECK), "a null-token ack must not satisfy this Reuse");
166
+ h.emitFromParent({ type: DG_COMPOSE_FILL_ACK_MESSAGE });
167
+ assert(!h.status()!.textContent!.includes(CHECK), "a token-less ack must not satisfy this Reuse");
168
+ // The real ack (echoing our token) still earns the success toast.
169
+ h.emitFromParent({ type: DG_COMPOSE_FILL_ACK_MESSAGE, token: fill!.data.token });
170
+ assert(h.status()!.textContent!.includes(CHECK), "the matching-token ack still resolves the Reuse");
171
+ } finally {
172
+ h.teardown();
173
+ }
174
+ });
175
+
176
+ test("#645: Reuse surfaces an honest error when the compose never acks (short timeout)", async () => {
177
+ const h = libraryHarness();
178
+ try {
179
+ await h.flush();
180
+ h.click(h.host!.querySelector("[data-reuse]"));
181
+ assert(!h.status()!.textContent!.includes(CHECK), "no success before the timeout");
182
+ await wait(80); // > ackTimeoutMs (40)
183
+ const s = h.status();
184
+ assert(s && /Couldn't reach the composer/.test(s.textContent!), "on timeout Reuse surfaces 'Couldn't reach the composer'");
185
+ assert(!s!.textContent!.includes(CHECK) && /status-err/.test(s!.className), "the timed-out Reuse is an error, never a ✓ success");
186
+ } finally {
187
+ h.teardown();
188
+ }
189
+ });
190
+
191
+ // ── Compose "Preview generated DI" ───────────────────────────────────────────────────────────────────
192
+ const PREVIEW_URL = `${ORIGIN}/app/api/actions/delivery-graph/preview`;
193
+ const STAGE_URL = `${ORIGIN}/app/api/actions/delivery-graph/stage`;
194
+ const IMPORT_URL = `${ORIGIN}/app/api/actions/delivery-graph/library/import`;
195
+ const composeFetch = async (input: unknown) => {
196
+ if (String(input) === PREVIEW_URL) {
197
+ return new Response(
198
+ JSON.stringify({ ok: true, title: "T", sideEffecting: false, nodeCount: 1, humanNodeCount: 0, sideEffectCount: 0, digest: "d1", sideEffects: [], mermaid: "graph TD", bpmn: "<xml/>" }),
199
+ { status: 200 },
200
+ );
201
+ }
202
+ return new Response(JSON.stringify({}), { status: 404 });
203
+ };
204
+
205
+ /** Boot the compose mount and drive Preview so a laid-out BPMN exists, then click "Preview generated DI". */
206
+ async function composePreviewHarness() {
207
+ const h = harness(
208
+ mountDeliveryGraphs as never,
209
+ { previewUrl: PREVIEW_URL, stageUrl: STAGE_URL, importUrl: IMPORT_URL, ackTimeoutMs: 40 },
210
+ composeFetch,
211
+ );
212
+ const jsonEl = h.host!.querySelector("#dg-json") as { value: string } | null;
213
+ assert(jsonEl, "compose mount must render the #dg-json textarea");
214
+ jsonEl!.value = '{"nodes":[],"edges":[]}';
215
+ h.click(h.host!.querySelector("#dg-preview"));
216
+ await h.flush(); // resolves the preview door → lastBpmn set → renders the "Preview generated DI" button
217
+ return h;
218
+ }
219
+
220
+ test("#645: 'Preview generated DI' shows NO optimistic ✓ toast — a neutral status until the host acks", async () => {
221
+ const h = await composePreviewHarness();
222
+ try {
223
+ const before = h.posted.length;
224
+ h.click(h.host!.querySelector("[data-preview-di]"));
225
+ const nav = h.posted.slice(before).find((p) => p.data.type === "nano-navigate");
226
+ assert(nav, "Preview DI must post nano-navigate up to the console");
227
+ assertEquals(nav!.data.target, "definitionPreview", "the nano-navigate targets the definitionPreview view");
228
+ const s = h.status();
229
+ assert(s && !s.textContent!.includes(CHECK), "Preview DI must NOT render a ✓ 'Opening…' toast synchronously (the #645 defect)");
230
+ assert(s && !/status-ok/.test(s.className), "the pre-ack Preview status must not carry the success (ok) tone");
231
+ } finally {
232
+ h.teardown();
233
+ }
234
+ });
235
+
236
+ test("#645: 'Preview generated DI' renders ✓ Opened only once the host acks the nano-navigate", async () => {
237
+ const h = await composePreviewHarness();
238
+ try {
239
+ h.click(h.host!.querySelector("[data-preview-di]"));
240
+ assert(!h.status()!.textContent!.includes(CHECK), "no success before the host ack");
241
+ h.emitFromParent({ type: NANO_NAVIGATE_ACK_MESSAGE, target: "definitionPreview" });
242
+ const s = h.status();
243
+ assert(s!.textContent!.includes(CHECK) && /Opened/.test(s!.textContent!), "on host ack Preview DI renders the ✓ Opened toast");
244
+ assert(/status-ok/.test(s!.className), "the acked Preview status carries the success tone");
245
+ } finally {
246
+ h.teardown();
247
+ }
248
+ });
249
+
250
+ test("#645: 'Preview generated DI' ignores a target-less nav ack — no forged ✓ Opened", async () => {
251
+ const h = await composePreviewHarness();
252
+ try {
253
+ h.click(h.host!.querySelector("[data-preview-di]"));
254
+ // An ack that omits `target` (or names a different one) could be any unrelated same-origin parent
255
+ // ack — it must NOT resolve the pending Preview (else the #645 false-positive toast returns).
256
+ h.emitFromParent({ type: NANO_NAVIGATE_ACK_MESSAGE });
257
+ assert(!h.status()!.textContent!.includes(CHECK), "a target-less nav ack must not satisfy the Preview");
258
+ h.emitFromParent({ type: NANO_NAVIGATE_ACK_MESSAGE, target: "someOtherView" });
259
+ assert(!h.status()!.textContent!.includes(CHECK), "an ack for a different target must not satisfy the Preview");
260
+ // The real ack (matching target) still earns the success toast.
261
+ h.emitFromParent({ type: NANO_NAVIGATE_ACK_MESSAGE, target: "definitionPreview" });
262
+ assert(h.status()!.textContent!.includes(CHECK), "the matching-target ack still resolves the Preview");
263
+ } finally {
264
+ h.teardown();
265
+ }
266
+ });
267
+
268
+ test("#645: 'Preview generated DI' surfaces an honest error when the host never acks (short timeout)", async () => {
269
+ const h = await composePreviewHarness();
270
+ try {
271
+ h.click(h.host!.querySelector("[data-preview-di]"));
272
+ assert(!h.status()!.textContent!.includes(CHECK), "no success before the timeout");
273
+ await wait(80); // > ackTimeoutMs (40)
274
+ const s = h.status();
275
+ assert(s && /Couldn't reach the console explorer/.test(s.textContent!), "on timeout Preview DI surfaces 'Couldn't reach the console explorer'");
276
+ assert(!s!.textContent!.includes(CHECK) && /status-err/.test(s!.className), "the timed-out Preview is an error, never a ✓ success");
277
+ } finally {
278
+ h.teardown();
279
+ }
280
+ });
@@ -71,7 +71,7 @@ test("#523: Reuse drives the compose fill seam over the shared host-bridge messa
71
71
  // Reuse loads the saved graph back into the SEPARATE compose App-View, so it posts the ONE shared
72
72
  // fill message (its type imported from ./mount.js, never re-declared) UP over the App-View boundary.
73
73
  assert(
74
- /import \{ DG_COMPOSE_FILL_MESSAGE \} from "\.\/mount\.js"/.test(LIBRARY_JS),
74
+ /import \{[^}]*\bDG_COMPOSE_FILL_MESSAGE\b[^}]*\} from "\.\/mount\.js"/.test(LIBRARY_JS),
75
75
  "library.mount.js must import DG_COMPOSE_FILL_MESSAGE from ./mount.js (the ONE source of truth for the fill message type)",
76
76
  );
77
77
  assert(/data-reuse=/.test(LIBRARY_JS), "library.mount.js must render a per-row Reuse affordance carrying the entry id");