@shardworks/spider-apparatus 0.1.225 → 0.1.226

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardworks/spider-apparatus",
3
- "version": "0.1.225",
3
+ "version": "0.1.226",
4
4
  "license": "ISC",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,17 +22,17 @@
22
22
  "hono": "^4.7.11",
23
23
  "yaml": "^2.0.0",
24
24
  "zod": "4.3.6",
25
- "@shardworks/fabricator-apparatus": "0.1.225",
26
- "@shardworks/clerk-apparatus": "0.1.225",
27
- "@shardworks/stacks-apparatus": "0.1.225",
28
- "@shardworks/tools-apparatus": "0.1.225",
29
- "@shardworks/codexes-apparatus": "0.1.225",
30
- "@shardworks/animator-apparatus": "0.1.225",
31
- "@shardworks/loom-apparatus": "0.1.225"
25
+ "@shardworks/fabricator-apparatus": "0.1.226",
26
+ "@shardworks/stacks-apparatus": "0.1.226",
27
+ "@shardworks/clerk-apparatus": "0.1.226",
28
+ "@shardworks/animator-apparatus": "0.1.226",
29
+ "@shardworks/tools-apparatus": "0.1.226",
30
+ "@shardworks/loom-apparatus": "0.1.226",
31
+ "@shardworks/codexes-apparatus": "0.1.226"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "25.5.0",
35
- "@shardworks/nexus-core": "0.1.225"
35
+ "@shardworks/nexus-core": "0.1.226"
36
36
  },
37
37
  "files": [
38
38
  "dist",
@@ -394,3 +394,329 @@ describe('spider.js rig list polling', () => {
394
394
  );
395
395
  });
396
396
  });
397
+
398
+ // ── Engine-detail in-place update (no flicker / no scroll reset) ────────
399
+
400
+ describe('spider.js engine-detail stable-skeleton + updater', () => {
401
+ it('defines updateEngineDetail as a separate function from showEngineDetail', () => {
402
+ assert.match(
403
+ spiderJs,
404
+ /function updateEngineDetail\(engine\)/,
405
+ 'should define updateEngineDetail for the poll path',
406
+ );
407
+ assert.match(
408
+ spiderJs,
409
+ /function showEngineDetail\(engine\)/,
410
+ 'should still define showEngineDetail for the click path',
411
+ );
412
+ });
413
+
414
+ it('defines a buildEngineDetailSkeleton that establishes stable field ids', () => {
415
+ assert.match(
416
+ spiderJs,
417
+ /function buildEngineDetailSkeleton\(/,
418
+ 'should define a one-time skeleton builder',
419
+ );
420
+ });
421
+
422
+ it('skeleton declares stable id containers for the value-bearing fields', () => {
423
+ // Spot-check a representative subset of the stable-id contract.
424
+ const expectedIds = [
425
+ 'ed-status',
426
+ 'ed-design-id',
427
+ 'ed-upstream',
428
+ 'ed-started-at',
429
+ 'ed-completed-at',
430
+ 'ed-elapsed',
431
+ 'ed-error',
432
+ 'ed-session-id',
433
+ 'ed-block-type',
434
+ 'ed-cost-input',
435
+ 'ed-cost-output',
436
+ 'ed-cost-usd',
437
+ 'ed-givens-code',
438
+ 'ed-yields-code',
439
+ 'ed-cancel-container',
440
+ ];
441
+ for (const id of expectedIds) {
442
+ assert.match(
443
+ spiderJs,
444
+ new RegExp(`id="${id}"`),
445
+ `skeleton should define a stable id container for ${id}`,
446
+ );
447
+ }
448
+ });
449
+
450
+ it('the old #cost-placeholder insertAdjacentHTML trick is gone', () => {
451
+ assert.doesNotMatch(
452
+ spiderJs,
453
+ /id="cost-placeholder"/,
454
+ 'skeleton should not use the old #cost-placeholder span',
455
+ );
456
+ assert.doesNotMatch(
457
+ spiderJs,
458
+ /insertAdjacentHTML\('beforebegin'/,
459
+ 'cost rendering should not use insertAdjacentHTML anymore',
460
+ );
461
+ });
462
+
463
+ it('the rig poll path calls updateEngineDetail (not showEngineDetail) for the selected engine', () => {
464
+ const pollBlock = spiderJs.match(
465
+ /function fetchCurrentRigQuiet[\s\S]*?(?=\n function )/,
466
+ );
467
+ assert.ok(pollBlock, 'should find fetchCurrentRigQuiet');
468
+ assert.match(
469
+ pollBlock[0],
470
+ /updateEngineDetail\(/,
471
+ 'fetchCurrentRigQuiet should call updateEngineDetail on poll',
472
+ );
473
+ assert.doesNotMatch(
474
+ pollBlock[0],
475
+ /showEngineDetail\(/,
476
+ 'fetchCurrentRigQuiet must NOT call showEngineDetail (that would re-open SSE and rebuild the panel)',
477
+ );
478
+ });
479
+
480
+ it('updateEngineDetail does not rewrite the engine-detail-body innerHTML', () => {
481
+ const updaterBlock = spiderJs.match(
482
+ /function updateEngineDetail\(engine\)[\s\S]*?(?=\n function )/,
483
+ );
484
+ assert.ok(updaterBlock, 'should find updateEngineDetail body');
485
+ assert.doesNotMatch(
486
+ updaterBlock[0],
487
+ /engine-detail-body[\s\S]*?innerHTML\s*=/,
488
+ 'updateEngineDetail must not rewrite #engine-detail-body innerHTML',
489
+ );
490
+ });
491
+ });
492
+
493
+ // ── SSE stream lifecycle decoupled from rig polling ─────────────────────
494
+
495
+ describe('spider.js SSE lifecycle decoupling', () => {
496
+ it('declares streamSessionId in module scope', () => {
497
+ assert.match(
498
+ spiderJs,
499
+ /var streamSessionId\s*=\s*null/,
500
+ 'should track streamSessionId in module scope',
501
+ );
502
+ });
503
+
504
+ it('declares streamDone in module scope (hoisted out of showEngineDetail)', () => {
505
+ // The streamDone flag must survive the function boundary so the SSE
506
+ // error handler can still reference it after stopSessionStream nulls
507
+ // out the EventSource reference.
508
+ assert.match(
509
+ spiderJs,
510
+ /^\s*var streamDone\s*=\s*false;?\s*$/m,
511
+ 'should declare streamDone in module scope',
512
+ );
513
+ });
514
+
515
+ it('defines ensureSessionStream that compares against streamSessionId', () => {
516
+ assert.match(
517
+ spiderJs,
518
+ /function ensureSessionStream\(/,
519
+ 'should define ensureSessionStream',
520
+ );
521
+ const ensureBlock = spiderJs.match(
522
+ /function ensureSessionStream[\s\S]*?(?=\n function )/,
523
+ );
524
+ assert.ok(ensureBlock, 'should find ensureSessionStream body');
525
+ assert.match(
526
+ ensureBlock[0],
527
+ /streamSessionId/,
528
+ 'ensureSessionStream should compare against streamSessionId',
529
+ );
530
+ });
531
+
532
+ it('showEngineDetail calls ensureSessionStream (not raw EventSource)', () => {
533
+ const showBlock = spiderJs.match(
534
+ /function showEngineDetail\(engine\)[\s\S]*?(?=\n function )/,
535
+ );
536
+ assert.ok(showBlock, 'should find showEngineDetail');
537
+ assert.match(
538
+ showBlock[0],
539
+ /ensureSessionStream\(/,
540
+ 'click path should go through ensureSessionStream',
541
+ );
542
+ assert.doesNotMatch(
543
+ showBlock[0],
544
+ /new EventSource/,
545
+ 'click path should not directly construct EventSource (delegated to openSessionStream)',
546
+ );
547
+ });
548
+
549
+ it('stopSessionStream clears streamSessionId so re-opens dedupe correctly', () => {
550
+ const stopBlock = spiderJs.match(
551
+ /function stopSessionStream\(\)[\s\S]*?(?=\n function )/,
552
+ );
553
+ assert.ok(stopBlock, 'should find stopSessionStream');
554
+ assert.match(
555
+ stopBlock[0],
556
+ /streamSessionId\s*=\s*null/,
557
+ 'stopSessionStream should null out streamSessionId',
558
+ );
559
+ });
560
+ });
561
+
562
+ // ── Transcript scroll preservation on all write paths ───────────────────
563
+
564
+ describe('spider.js transcript scroll preservation', () => {
565
+ it('SSE chunk handler captures atBottom before mutating textarea', () => {
566
+ const chunkBlock = spiderJs.match(
567
+ /addEventListener\('chunk',\s*function[\s\S]*?\}\);/,
568
+ );
569
+ assert.ok(chunkBlock, 'should find chunk handler');
570
+ assert.match(
571
+ chunkBlock[0],
572
+ /var atBottom\s*=/,
573
+ 'chunk handler should capture atBottom before mutation',
574
+ );
575
+ assert.match(
576
+ chunkBlock[0],
577
+ /if\s*\(atBottom\)/,
578
+ 'chunk handler should restore scroll only when atBottom was true',
579
+ );
580
+ });
581
+
582
+ it('SSE transcript handler captures atBottom before replacing textarea value', () => {
583
+ const transcriptBlock = spiderJs.match(
584
+ /addEventListener\('transcript',\s*function[\s\S]*?\}\);/,
585
+ );
586
+ assert.ok(transcriptBlock, 'should find transcript handler');
587
+ assert.match(
588
+ transcriptBlock[0],
589
+ /var atBottom\s*=/,
590
+ 'transcript handler should capture atBottom before mutation',
591
+ );
592
+ assert.match(
593
+ transcriptBlock[0],
594
+ /if\s*\(atBottom\)/,
595
+ 'transcript handler should restore scroll only when atBottom was true',
596
+ );
597
+ });
598
+
599
+ it('noStream polling fallback also uses the atBottom pattern', () => {
600
+ // The noStream path's atBottom guard predates this fix and must remain.
601
+ const doneBlock = spiderJs.match(
602
+ /addEventListener\('done',\s*function[\s\S]*?if \(data\.noStream[\s\S]*?\}\);\s*$/m,
603
+ );
604
+ assert.ok(doneBlock, 'should find done handler with noStream branch');
605
+ assert.match(
606
+ doneBlock[0],
607
+ /var atBottom\s*=/,
608
+ 'noStream fallback should retain its atBottom capture',
609
+ );
610
+ });
611
+ });
612
+
613
+ // ── Pipeline renderer keyed in-place update ─────────────────────────────
614
+
615
+ describe('spider.js pipeline keyed update', () => {
616
+ it('renderPipelineInto indexes existing nodes by data-engine-id', () => {
617
+ const block = spiderJs.match(
618
+ /function renderPipelineInto\([\s\S]*?(?=\n function )/,
619
+ );
620
+ assert.ok(block, 'should find renderPipelineInto');
621
+ assert.match(
622
+ block[0],
623
+ /querySelectorAll\(['"]\.pipeline-node['"]\)/,
624
+ 'renderPipelineInto should look up existing pipeline-node children',
625
+ );
626
+ assert.match(
627
+ block[0],
628
+ /getAttribute\(['"]data-engine-id['"]\)/,
629
+ 'renderPipelineInto should key existing nodes by data-engine-id',
630
+ );
631
+ });
632
+
633
+ it('renderPipelineInto has a fast path that patches in place when order is unchanged', () => {
634
+ const block = spiderJs.match(
635
+ /function renderPipelineInto\([\s\S]*?(?=\n function )/,
636
+ );
637
+ assert.ok(block, 'should find renderPipelineInto');
638
+ assert.match(
639
+ block[0],
640
+ /orderUnchanged/,
641
+ 'should compute an orderUnchanged flag for the keyed fast path',
642
+ );
643
+ });
644
+
645
+ it('exposes updatePipelineNode for in-place badge/selection updates', () => {
646
+ assert.match(
647
+ spiderJs,
648
+ /function updatePipelineNode\(node, engine\)/,
649
+ 'should define updatePipelineNode helper for in-place node updates',
650
+ );
651
+ });
652
+
653
+ it('pipeline node carries a class hook for the status badge', () => {
654
+ // Without a class hook, the keyed-update path can't unambiguously
655
+ // target the status badge inside a node.
656
+ assert.match(
657
+ spiderJs,
658
+ /pipeline-node-status/,
659
+ 'pipeline-node-status class hook should mark the badge for updates',
660
+ );
661
+ });
662
+ });
663
+
664
+ // ── Cost fetch gated on transition to completed ─────────────────────────
665
+
666
+ describe('spider.js session cost fetch gating', () => {
667
+ it('declares costFetchedFor cache in module scope', () => {
668
+ assert.match(
669
+ spiderJs,
670
+ /var costFetchedFor\s*=\s*\{\}/,
671
+ 'should declare costFetchedFor cache to gate the cost fetch',
672
+ );
673
+ });
674
+
675
+ it('cost fetch is not fired unconditionally on every render', () => {
676
+ // Locate the call to /api/session/show — it must be inside a guarded
677
+ // helper, not directly inside updateEngineDetail's per-render path.
678
+ const updaterBlock = spiderJs.match(
679
+ /function updateEngineDetail\(engine\)[\s\S]*?(?=\n function )/,
680
+ );
681
+ assert.ok(updaterBlock, 'should find updateEngineDetail');
682
+ assert.doesNotMatch(
683
+ updaterBlock[0],
684
+ /fetch\(['"]\/api\/session\/show/,
685
+ 'updateEngineDetail should not call /api/session/show directly',
686
+ );
687
+ assert.match(
688
+ updaterBlock[0],
689
+ /costFetchedFor\[engine\.id\]/,
690
+ 'updateEngineDetail should consult costFetchedFor before requesting cost',
691
+ );
692
+ });
693
+
694
+ it('cost fetch helper exists and targets /api/session/show', () => {
695
+ assert.match(
696
+ spiderJs,
697
+ /function fetchSessionCost\(/,
698
+ 'should define a dedicated fetchSessionCost helper',
699
+ );
700
+ const helperBlock = spiderJs.match(
701
+ /function fetchSessionCost\([\s\S]*?(?=\n function )/,
702
+ );
703
+ assert.ok(helperBlock, 'should find fetchSessionCost body');
704
+ assert.match(
705
+ helperBlock[0],
706
+ /\/api\/session\/show\?id=/,
707
+ 'fetchSessionCost should call the session show endpoint',
708
+ );
709
+ });
710
+
711
+ it('updateEngineDetail tracks engine status across polls to detect transition', () => {
712
+ const updaterBlock = spiderJs.match(
713
+ /function updateEngineDetail\(engine\)[\s\S]*?(?=\n function )/,
714
+ );
715
+ assert.ok(updaterBlock, 'should find updateEngineDetail');
716
+ assert.match(
717
+ updaterBlock[0],
718
+ /engineStatusByEngineId/,
719
+ 'updateEngineDetail should record engine status to detect transitions',
720
+ );
721
+ });
722
+ });