sh3-core 0.14.3 → 0.15.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/dist/api.d.ts +3 -1
- package/dist/api.js +4 -0
- package/dist/app/store/verbs.js +4 -0
- package/dist/contributions/index.d.ts +1 -1
- package/dist/contributions/index.js +1 -1
- package/dist/contributions/registry.d.ts +7 -0
- package/dist/contributions/registry.js +24 -4
- package/dist/contributions/registry.test.js +56 -1
- package/dist/contributions/types.d.ts +9 -0
- package/dist/overlays/FloatFrame.svelte +132 -8
- package/dist/overlays/FloatFrame.svelte.d.ts +1 -1
- package/dist/overlays/FloatLayer.svelte +2 -2
- package/dist/overlays/float.d.ts +21 -0
- package/dist/overlays/float.js +66 -0
- package/dist/overlays/float.test.js +359 -0
- package/dist/overlays/floatMaximized.svelte.d.ts +4 -0
- package/dist/overlays/floatMaximized.svelte.js +30 -0
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/runVerb-shell.test.d.ts +1 -0
- package/dist/runtime/runVerb-shell.test.js +231 -0
- package/dist/runtime/runVerb.d.ts +10 -0
- package/dist/runtime/runVerb.js +97 -0
- package/dist/runtime/runVerb.test.d.ts +1 -0
- package/dist/runtime/runVerb.test.js +132 -0
- package/dist/sh3core-shard/sh3coreShard.svelte.d.ts +7 -0
- package/dist/sh3core-shard/sh3coreShard.svelte.js +23 -0
- package/dist/shards/activate-contributions.test.js +31 -0
- package/dist/shards/activate-runtime.test.d.ts +1 -0
- package/dist/shards/activate-runtime.test.js +201 -0
- package/dist/shards/activate.svelte.js +25 -3
- package/dist/shards/registry.d.ts +11 -1
- package/dist/shards/registry.js +16 -4
- package/dist/shards/registry.test.js +24 -16
- package/dist/shards/types.d.ts +46 -1
- package/dist/shell-shard/Terminal.svelte +1 -0
- package/dist/shell-shard/registry-resolve.test.js +2 -2
- package/dist/shell-shard/shellApi.d.ts +3 -0
- package/dist/shell-shard/shellApi.js +143 -0
- package/dist/shell-shard/shellShard.svelte.d.ts +1 -7
- package/dist/shell-shard/shellShard.svelte.js +8 -163
- package/dist/shell-shard/verbs/apps.js +2 -0
- package/dist/shell-shard/verbs/cat.js +1 -0
- package/dist/shell-shard/verbs/help.js +5 -1
- package/dist/shell-shard/verbs/help.svelte.test.d.ts +1 -0
- package/dist/shell-shard/verbs/help.svelte.test.js +53 -0
- package/dist/shell-shard/verbs/ls.js +1 -0
- package/dist/shell-shard/verbs/session.js +2 -0
- package/dist/shell-shard/verbs/shards.js +1 -0
- package/dist/shell-shard/verbs/views.js +5 -0
- package/dist/shell-shard/verbs/zones.js +2 -0
- package/dist/verbs/types.d.ts +69 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -477,3 +477,362 @@ describe('floats — F.9 meta threads to MountContext', () => {
|
|
|
477
477
|
expect(captured).toEqual({ kind: 'picker', color: '#abc' });
|
|
478
478
|
});
|
|
479
479
|
});
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// F.10..F.20 — resize grip + maximize behavior
|
|
482
|
+
// (spec: docs/superpowers/specs/2026-05-07-float-resize-maximize-design.md)
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
import { buildToggleMaximizeAction } from '../sh3core-shard/sh3coreShard.svelte';
|
|
485
|
+
function pointerEvent(type, x, y, opts = {}) {
|
|
486
|
+
return new PointerEvent(type, Object.assign({ bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', clientX: x, clientY: y, button: 0, buttons: type === 'pointerup' ? 0 : 1 }, opts));
|
|
487
|
+
}
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
// F.10 — resize mutates entry.size
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
describe('floats — F.10 resize mutates entry.size', () => {
|
|
492
|
+
beforeEach(() => {
|
|
493
|
+
resetFramework();
|
|
494
|
+
bindManagerToStore();
|
|
495
|
+
});
|
|
496
|
+
it('pointerdown + pointermove on the grip grows entry.size by the pointer delta', async () => {
|
|
497
|
+
const { container } = renderWithShell(FloatLayer, {});
|
|
498
|
+
const id = floatManager.open('test:view', {
|
|
499
|
+
title: 'Resizable',
|
|
500
|
+
position: { x: 100, y: 100 },
|
|
501
|
+
size: { w: 600, h: 400 },
|
|
502
|
+
});
|
|
503
|
+
await tick();
|
|
504
|
+
const grip = container.querySelector('[role="dialog"][aria-label="Resizable"] .sh3-float-resize-grip');
|
|
505
|
+
expect(grip).toBeTruthy();
|
|
506
|
+
grip.dispatchEvent(pointerEvent('pointerdown', 700, 500));
|
|
507
|
+
grip.dispatchEvent(pointerEvent('pointermove', 750, 540));
|
|
508
|
+
grip.dispatchEvent(pointerEvent('pointerup', 750, 540));
|
|
509
|
+
await tick();
|
|
510
|
+
const entry = floatManager.list().find((f) => f.id === id);
|
|
511
|
+
expect(entry.size).toEqual({ w: 650, h: 440 });
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
// F.11 — resize clamps at computeMinSize(entry.content)
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
describe('floats — F.11 resize clamp', () => {
|
|
518
|
+
beforeEach(() => {
|
|
519
|
+
resetFramework();
|
|
520
|
+
bindManagerToStore();
|
|
521
|
+
});
|
|
522
|
+
it('drag delta below the content min clamps to the min size', async () => {
|
|
523
|
+
const { container } = renderWithShell(FloatLayer, {});
|
|
524
|
+
const id = floatManager.open('test:view', {
|
|
525
|
+
title: 'Tiny',
|
|
526
|
+
position: { x: 0, y: 0 },
|
|
527
|
+
size: { w: 200, h: 150 },
|
|
528
|
+
});
|
|
529
|
+
await tick();
|
|
530
|
+
const grip = container.querySelector('[role="dialog"][aria-label="Tiny"] .sh3-float-resize-grip');
|
|
531
|
+
expect(grip).toBeTruthy();
|
|
532
|
+
// Start at (200,150), move pointer back by ( -1000, -1000 ) — wildly past
|
|
533
|
+
// the floor. The clamp should lock at the slot's DEFAULT_SLOT_MIN (120×80).
|
|
534
|
+
grip.dispatchEvent(pointerEvent('pointerdown', 200, 150));
|
|
535
|
+
grip.dispatchEvent(pointerEvent('pointermove', -800, -850));
|
|
536
|
+
grip.dispatchEvent(pointerEvent('pointerup', -800, -850));
|
|
537
|
+
await tick();
|
|
538
|
+
const entry = floatManager.list().find((f) => f.id === id);
|
|
539
|
+
expect(entry.size.w).toBe(120);
|
|
540
|
+
expect(entry.size.h).toBe(80);
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
// F.12 — maximize / restore round-trip
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
describe('floats — F.12 maximize / restore', () => {
|
|
547
|
+
beforeEach(() => {
|
|
548
|
+
resetFramework();
|
|
549
|
+
bindManagerToStore();
|
|
550
|
+
});
|
|
551
|
+
it('maximize snapshots prev rect, overrides with bounds; restore rolls back', () => {
|
|
552
|
+
const id = floatManager.open('test:view', {
|
|
553
|
+
title: 'M',
|
|
554
|
+
position: { x: 50, y: 60 },
|
|
555
|
+
size: { w: 300, h: 200 },
|
|
556
|
+
});
|
|
557
|
+
expect(floatManager.isMaximized(id)).toBe(false);
|
|
558
|
+
floatManager.maximize(id);
|
|
559
|
+
expect(floatManager.isMaximized(id)).toBe(true);
|
|
560
|
+
let entry = floatManager.list().find((f) => f.id === id);
|
|
561
|
+
expect(entry.position).toEqual({ x: 0, y: 0 });
|
|
562
|
+
expect(entry.size).toEqual({ w: 1024, h: 768 });
|
|
563
|
+
floatManager.restore(id);
|
|
564
|
+
expect(floatManager.isMaximized(id)).toBe(false);
|
|
565
|
+
entry = floatManager.list().find((f) => f.id === id);
|
|
566
|
+
expect(entry.position).toEqual({ x: 50, y: 60 });
|
|
567
|
+
expect(entry.size).toEqual({ w: 300, h: 200 });
|
|
568
|
+
});
|
|
569
|
+
it('maximize is a no-op when already maximized; restore is a no-op when not', () => {
|
|
570
|
+
const id = floatManager.open('test:view', {
|
|
571
|
+
title: 'M',
|
|
572
|
+
position: { x: 10, y: 20 },
|
|
573
|
+
size: { w: 300, h: 200 },
|
|
574
|
+
});
|
|
575
|
+
floatManager.maximize(id);
|
|
576
|
+
floatManager.maximize(id); // second call must not overwrite the snapshot
|
|
577
|
+
floatManager.restore(id);
|
|
578
|
+
const entry = floatManager.list().find((f) => f.id === id);
|
|
579
|
+
expect(entry.position).toEqual({ x: 10, y: 20 });
|
|
580
|
+
expect(entry.size).toEqual({ w: 300, h: 200 });
|
|
581
|
+
floatManager.restore(id); // no-op when not maximized
|
|
582
|
+
expect(floatManager.isMaximized(id)).toBe(false);
|
|
583
|
+
});
|
|
584
|
+
it('toggleMaximize alternates between maximized and restored', () => {
|
|
585
|
+
const id = floatManager.open('test:view', {
|
|
586
|
+
title: 'T',
|
|
587
|
+
position: { x: 10, y: 20 },
|
|
588
|
+
size: { w: 300, h: 200 },
|
|
589
|
+
});
|
|
590
|
+
floatManager.toggleMaximize(id);
|
|
591
|
+
expect(floatManager.isMaximized(id)).toBe(true);
|
|
592
|
+
floatManager.toggleMaximize(id);
|
|
593
|
+
expect(floatManager.isMaximized(id)).toBe(false);
|
|
594
|
+
const entry = floatManager.list().find((f) => f.id === id);
|
|
595
|
+
expect(entry.position).toEqual({ x: 10, y: 20 });
|
|
596
|
+
expect(entry.size).toEqual({ w: 300, h: 200 });
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
// ---------------------------------------------------------------------------
|
|
600
|
+
// F.13 — closeFloat clears the maximize sidecar
|
|
601
|
+
// ---------------------------------------------------------------------------
|
|
602
|
+
describe('floats — F.13 close clears sidecar', () => {
|
|
603
|
+
beforeEach(() => {
|
|
604
|
+
resetFramework();
|
|
605
|
+
bindManagerToStore();
|
|
606
|
+
});
|
|
607
|
+
it('closing a maximized float removes it from isMaximized', () => {
|
|
608
|
+
const id = floatManager.open('test:view', { title: 'C' });
|
|
609
|
+
floatManager.maximize(id);
|
|
610
|
+
expect(floatManager.isMaximized(id)).toBe(true);
|
|
611
|
+
floatManager.close(id);
|
|
612
|
+
expect(floatManager.isMaximized(id)).toBe(false);
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
// ---------------------------------------------------------------------------
|
|
616
|
+
// F.14 — header double-click toggles maximize
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
describe('floats — F.14 header double-click toggle', () => {
|
|
619
|
+
beforeEach(() => {
|
|
620
|
+
resetFramework();
|
|
621
|
+
bindManagerToStore();
|
|
622
|
+
});
|
|
623
|
+
it('dblclick on the header toggles maximize', async () => {
|
|
624
|
+
const { container } = renderWithShell(FloatLayer, {});
|
|
625
|
+
const id = floatManager.open('test:view', {
|
|
626
|
+
title: 'DblC',
|
|
627
|
+
position: { x: 30, y: 40 },
|
|
628
|
+
size: { w: 250, h: 180 },
|
|
629
|
+
});
|
|
630
|
+
await tick();
|
|
631
|
+
const header = container.querySelector('[role="dialog"][aria-label="DblC"] .sh3-float-header');
|
|
632
|
+
expect(header).toBeTruthy();
|
|
633
|
+
header.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
|
634
|
+
await tick();
|
|
635
|
+
expect(floatManager.isMaximized(id)).toBe(true);
|
|
636
|
+
let entry = floatManager.list().find((f) => f.id === id);
|
|
637
|
+
expect(entry.position).toEqual({ x: 0, y: 0 });
|
|
638
|
+
expect(entry.size).toEqual({ w: 1024, h: 768 });
|
|
639
|
+
header.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
|
640
|
+
await tick();
|
|
641
|
+
expect(floatManager.isMaximized(id)).toBe(false);
|
|
642
|
+
entry = floatManager.list().find((f) => f.id === id);
|
|
643
|
+
expect(entry.position).toEqual({ x: 30, y: 40 });
|
|
644
|
+
expect(entry.size).toEqual({ w: 250, h: 180 });
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
// ---------------------------------------------------------------------------
|
|
648
|
+
// F.15 — header maximize button toggles
|
|
649
|
+
// ---------------------------------------------------------------------------
|
|
650
|
+
describe('floats — F.15 maximize button toggles', () => {
|
|
651
|
+
beforeEach(() => {
|
|
652
|
+
resetFramework();
|
|
653
|
+
bindManagerToStore();
|
|
654
|
+
});
|
|
655
|
+
it('clicking the maximize button toggles state and swaps the glyph/aria-label', async () => {
|
|
656
|
+
var _a, _b, _c;
|
|
657
|
+
const { container } = renderWithShell(FloatLayer, {});
|
|
658
|
+
const id = floatManager.open('test:view', { title: 'Btn' });
|
|
659
|
+
await tick();
|
|
660
|
+
let btn = container.querySelector('[role="dialog"][aria-label="Btn"] button.sh3-float-maximize');
|
|
661
|
+
expect(btn).toBeTruthy();
|
|
662
|
+
expect(btn.getAttribute('aria-label')).toBe('Maximize float');
|
|
663
|
+
expect((_a = btn.textContent) === null || _a === void 0 ? void 0 : _a.trim()).toBe('\u{1F5D6}');
|
|
664
|
+
btn.click();
|
|
665
|
+
await tick();
|
|
666
|
+
expect(floatManager.isMaximized(id)).toBe(true);
|
|
667
|
+
btn = container.querySelector('[role="dialog"][aria-label="Btn"] button.sh3-float-maximize');
|
|
668
|
+
expect(btn.getAttribute('aria-label')).toBe('Restore float');
|
|
669
|
+
expect((_b = btn.textContent) === null || _b === void 0 ? void 0 : _b.trim()).toBe('\u{1F5D7}');
|
|
670
|
+
btn.click();
|
|
671
|
+
await tick();
|
|
672
|
+
expect(floatManager.isMaximized(id)).toBe(false);
|
|
673
|
+
btn = container.querySelector('[role="dialog"][aria-label="Btn"] button.sh3-float-maximize');
|
|
674
|
+
expect(btn.getAttribute('aria-label')).toBe('Maximize float');
|
|
675
|
+
expect((_c = btn.textContent) === null || _c === void 0 ? void 0 : _c.trim()).toBe('\u{1F5D6}');
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
679
|
+
// F.16 — header double-click on close/maximize buttons does not toggle
|
|
680
|
+
// ---------------------------------------------------------------------------
|
|
681
|
+
describe('floats — F.16 dblclick on header buttons does not toggle', () => {
|
|
682
|
+
beforeEach(() => {
|
|
683
|
+
resetFramework();
|
|
684
|
+
bindManagerToStore();
|
|
685
|
+
});
|
|
686
|
+
it('dblclick on the maximize button does not invoke header dblclick toggle', async () => {
|
|
687
|
+
const { container } = renderWithShell(FloatLayer, {});
|
|
688
|
+
const id = floatManager.open('test:view', { title: 'NoT' });
|
|
689
|
+
await tick();
|
|
690
|
+
const btn = container.querySelector('[role="dialog"][aria-label="NoT"] button.sh3-float-maximize');
|
|
691
|
+
expect(btn).toBeTruthy();
|
|
692
|
+
// Single click maximizes; the dblclick must hit the same element so
|
|
693
|
+
// its target.closest('.sh3-float-maximize') returns the button and
|
|
694
|
+
// the header dblclick handler bails. Net effect: one transition only.
|
|
695
|
+
btn.click();
|
|
696
|
+
btn.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
|
697
|
+
await tick();
|
|
698
|
+
expect(floatManager.isMaximized(id)).toBe(true);
|
|
699
|
+
});
|
|
700
|
+
it('dblclick on the close button does not toggle maximize', async () => {
|
|
701
|
+
const { container } = renderWithShell(FloatLayer, {});
|
|
702
|
+
const id = floatManager.open('test:view', { title: 'CloseDbl' });
|
|
703
|
+
floatManager.maximize(id);
|
|
704
|
+
await tick();
|
|
705
|
+
const closeBtn = container.querySelector('[role="dialog"][aria-label="CloseDbl"] button.sh3-float-close');
|
|
706
|
+
expect(closeBtn).toBeTruthy();
|
|
707
|
+
closeBtn.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
|
708
|
+
await tick();
|
|
709
|
+
// dblclick on the close button must not flip the maximize state.
|
|
710
|
+
// (The single click would also close the float, but dblclick alone
|
|
711
|
+
// — without the click that fires the close handler — should leave
|
|
712
|
+
// the maximize state untouched.)
|
|
713
|
+
expect(floatManager.isMaximized(id)).toBe(true);
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
// ---------------------------------------------------------------------------
|
|
717
|
+
// F.17 — implicit un-maximize on header drag
|
|
718
|
+
// ---------------------------------------------------------------------------
|
|
719
|
+
describe('floats — F.17 implicit un-maximize on drag', () => {
|
|
720
|
+
beforeEach(() => {
|
|
721
|
+
resetFramework();
|
|
722
|
+
bindManagerToStore();
|
|
723
|
+
});
|
|
724
|
+
it('dragging the header while maximized clears isMaximized and keeps the post-drag rect', async () => {
|
|
725
|
+
const { container } = renderWithShell(FloatLayer, {});
|
|
726
|
+
const id = floatManager.open('test:view', {
|
|
727
|
+
title: 'Drag',
|
|
728
|
+
position: { x: 100, y: 100 },
|
|
729
|
+
size: { w: 300, h: 200 },
|
|
730
|
+
});
|
|
731
|
+
floatManager.maximize(id);
|
|
732
|
+
await tick();
|
|
733
|
+
const header = container.querySelector('[role="dialog"][aria-label="Drag"] .sh3-float-header');
|
|
734
|
+
expect(header).toBeTruthy();
|
|
735
|
+
// Float is at (0,0) sized (1024,768). Pointerdown at (50,10) on header
|
|
736
|
+
// sets dragOffset = (50, 10). Move to (200, 110): position = (150, 100).
|
|
737
|
+
header.dispatchEvent(pointerEvent('pointerdown', 50, 10));
|
|
738
|
+
header.dispatchEvent(pointerEvent('pointermove', 200, 110));
|
|
739
|
+
header.dispatchEvent(pointerEvent('pointerup', 200, 110));
|
|
740
|
+
await tick();
|
|
741
|
+
expect(floatManager.isMaximized(id)).toBe(false);
|
|
742
|
+
const entry = floatManager.list().find((f) => f.id === id);
|
|
743
|
+
// No rollback to the saved (100,100) prev rect; we kept the post-drag rect.
|
|
744
|
+
expect(entry.position).toEqual({ x: 150, y: 100 });
|
|
745
|
+
expect(entry.size).toEqual({ w: 1024, h: 768 });
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
// ---------------------------------------------------------------------------
|
|
749
|
+
// F.18 — implicit un-maximize on resize
|
|
750
|
+
// ---------------------------------------------------------------------------
|
|
751
|
+
describe('floats — F.18 implicit un-maximize on resize', () => {
|
|
752
|
+
beforeEach(() => {
|
|
753
|
+
resetFramework();
|
|
754
|
+
bindManagerToStore();
|
|
755
|
+
});
|
|
756
|
+
it('resizing the grip while maximized clears isMaximized and keeps the post-resize rect', async () => {
|
|
757
|
+
const { container } = renderWithShell(FloatLayer, {});
|
|
758
|
+
const id = floatManager.open('test:view', {
|
|
759
|
+
title: 'Rsz',
|
|
760
|
+
position: { x: 200, y: 200 },
|
|
761
|
+
size: { w: 300, h: 200 },
|
|
762
|
+
});
|
|
763
|
+
floatManager.maximize(id);
|
|
764
|
+
await tick();
|
|
765
|
+
const grip = container.querySelector('[role="dialog"][aria-label="Rsz"] .sh3-float-resize-grip');
|
|
766
|
+
expect(grip).toBeTruthy();
|
|
767
|
+
// Maximized: size = (1024, 768). Pointerdown at (1024,768), move to
|
|
768
|
+
// (924, 668) — delta (-100, -100). New size: (924, 668).
|
|
769
|
+
grip.dispatchEvent(pointerEvent('pointerdown', 1024, 768));
|
|
770
|
+
grip.dispatchEvent(pointerEvent('pointermove', 924, 668));
|
|
771
|
+
grip.dispatchEvent(pointerEvent('pointerup', 924, 668));
|
|
772
|
+
await tick();
|
|
773
|
+
expect(floatManager.isMaximized(id)).toBe(false);
|
|
774
|
+
const entry = floatManager.list().find((f) => f.id === id);
|
|
775
|
+
// Position is whatever the maximized rect left it (0,0) — no rollback.
|
|
776
|
+
expect(entry.position).toEqual({ x: 0, y: 0 });
|
|
777
|
+
expect(entry.size).toEqual({ w: 924, h: 668 });
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
// ---------------------------------------------------------------------------
|
|
781
|
+
// F.19 — sh3.float.toggleMaximize action
|
|
782
|
+
// ---------------------------------------------------------------------------
|
|
783
|
+
describe('floats — F.19 toggleMaximize action', () => {
|
|
784
|
+
beforeEach(() => {
|
|
785
|
+
resetFramework();
|
|
786
|
+
bindManagerToStore();
|
|
787
|
+
});
|
|
788
|
+
it('disabled when no floats are open; enabled when at least one is', () => {
|
|
789
|
+
const action = buildToggleMaximizeAction();
|
|
790
|
+
const isDisabled = () => typeof action.disabled === 'function' ? action.disabled() : !!action.disabled;
|
|
791
|
+
expect(isDisabled()).toBe(true);
|
|
792
|
+
const id = floatManager.open('test:view', { title: 'A' });
|
|
793
|
+
expect(isDisabled()).toBe(false);
|
|
794
|
+
floatManager.close(id);
|
|
795
|
+
expect(isDisabled()).toBe(true);
|
|
796
|
+
});
|
|
797
|
+
it('run() toggles the topmost float (last in list)', () => {
|
|
798
|
+
const action = buildToggleMaximizeAction();
|
|
799
|
+
const a = floatManager.open('test:view', { title: 'A' });
|
|
800
|
+
const b = floatManager.open('test:view', { title: 'B' });
|
|
801
|
+
// Topmost is b (last opened).
|
|
802
|
+
action.run({
|
|
803
|
+
action: { id: action.id, label: 'Toggle Float Maximize' },
|
|
804
|
+
appId: null,
|
|
805
|
+
invokedVia: 'palette',
|
|
806
|
+
dispatch: () => { },
|
|
807
|
+
});
|
|
808
|
+
expect(floatManager.isMaximized(b)).toBe(true);
|
|
809
|
+
expect(floatManager.isMaximized(a)).toBe(false);
|
|
810
|
+
// Toggle again — now b restores.
|
|
811
|
+
action.run({
|
|
812
|
+
action: { id: action.id, label: 'Toggle Float Maximize' },
|
|
813
|
+
appId: null,
|
|
814
|
+
invokedVia: 'palette',
|
|
815
|
+
dispatch: () => { },
|
|
816
|
+
});
|
|
817
|
+
expect(floatManager.isMaximized(b)).toBe(false);
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
// ---------------------------------------------------------------------------
|
|
821
|
+
// F.20 — dismissable float has resize grip and is not dismissed by it
|
|
822
|
+
// ---------------------------------------------------------------------------
|
|
823
|
+
describe('floats — F.20 dismissable + grip', () => {
|
|
824
|
+
beforeEach(() => {
|
|
825
|
+
resetFramework();
|
|
826
|
+
bindManagerToStore();
|
|
827
|
+
});
|
|
828
|
+
it('dismissable picker has a resize grip; pointerdown on the grip does not dismiss', async () => {
|
|
829
|
+
const { container } = renderWithShell(FloatLayer, {});
|
|
830
|
+
const id = floatManager.open('test:view', { dismissable: true, title: 'Pick' });
|
|
831
|
+
await tick();
|
|
832
|
+
const grip = container.querySelector('[role="dialog"][aria-label="Pick"] .sh3-float-resize-grip');
|
|
833
|
+
expect(grip).toBeTruthy();
|
|
834
|
+
grip.dispatchEvent(pointerEvent('pointerdown', 100, 100));
|
|
835
|
+
await tick();
|
|
836
|
+
expect(floatManager.list().some((f) => f.id === id)).toBe(true);
|
|
837
|
+
});
|
|
838
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function setMaximizedReactive(id: string, value: boolean): void;
|
|
2
|
+
export declare function readMaximizedReactive(id: string): boolean;
|
|
3
|
+
/** Test-only reset. Mirrors __resetFloatManagerForTest. */
|
|
4
|
+
export declare function __resetMaximizedReactiveForTest(): void;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Reactive mirror for the float manager's maximize sidecar.
|
|
3
|
+
*
|
|
4
|
+
* The canonical "is float X maximized?" state lives in `float.ts` as a
|
|
5
|
+
* plain Map<string, { position, size }>. That map is not Svelte-reactive
|
|
6
|
+
* (Svelte's `$state` does NOT deeply proxy Map/Set keys/values). A
|
|
7
|
+
* SvelteMap from `svelte/reactivity` mirrors the boolean form here.
|
|
8
|
+
* Components read via `readMaximizedReactive(id)` (or
|
|
9
|
+
* `floatManager.isMaximized(id)`, which forwards to it); the manager
|
|
10
|
+
* calls `setMaximizedReactive(id, ...)` on every state change.
|
|
11
|
+
*
|
|
12
|
+
* Kept in a tiny `.svelte.ts` file to avoid renaming `float.ts` (and its
|
|
13
|
+
* ~10 importers) just to access runes / reactive collections.
|
|
14
|
+
*/
|
|
15
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
16
|
+
const data = new SvelteMap();
|
|
17
|
+
export function setMaximizedReactive(id, value) {
|
|
18
|
+
if (value)
|
|
19
|
+
data.set(id, true);
|
|
20
|
+
else
|
|
21
|
+
data.delete(id);
|
|
22
|
+
}
|
|
23
|
+
export function readMaximizedReactive(id) {
|
|
24
|
+
var _a;
|
|
25
|
+
return (_a = data.get(id)) !== null && _a !== void 0 ? _a : false;
|
|
26
|
+
}
|
|
27
|
+
/** Test-only reset. Mirrors __resetFloatManagerForTest. */
|
|
28
|
+
export function __resetMaximizedReactiveForTest() {
|
|
29
|
+
data.clear();
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { runVerbProgrammatic } from './runVerb';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Integration tests for shell-shard verbs invoked via runVerbProgrammatic.
|
|
3
|
+
*
|
|
4
|
+
* Activates the real shell-shard once per test and asserts the captured
|
|
5
|
+
* scrollback shape for every verb we flagged `programmatic: true`. This is
|
|
6
|
+
* the contract AI consumers rely on when reading `out.scrollback[*].props.data.*`
|
|
7
|
+
* — keep the shapes here in sync with the verb bodies.
|
|
8
|
+
*
|
|
9
|
+
* Listed in vitest.workspace.ts under the `dom` project because shell-shard
|
|
10
|
+
* transitively imports Svelte rich components.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
13
|
+
import { MemoryDocumentBackend } from '../documents/backends';
|
|
14
|
+
import { __setDocumentBackend, __setTenantId } from '../documents/config';
|
|
15
|
+
import { registerShard, activateShard, __resetShardRegistryForTest, } from '../shards/activate.svelte';
|
|
16
|
+
import { __resetViewRegistryForTest } from '../shards/registry';
|
|
17
|
+
import { __resetActionsRegistryForTest } from '../actions/registry';
|
|
18
|
+
import { runVerbProgrammatic } from './runVerb';
|
|
19
|
+
import { shellShard } from '../shell-shard/shellShard.svelte';
|
|
20
|
+
import { registerApp, __resetAppRegistryForTest, } from '../apps/registry.svelte';
|
|
21
|
+
describe('shell-shard programmatic verbs (integration)', () => {
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
__resetShardRegistryForTest();
|
|
24
|
+
__resetViewRegistryForTest();
|
|
25
|
+
__resetActionsRegistryForTest();
|
|
26
|
+
__resetAppRegistryForTest();
|
|
27
|
+
__setDocumentBackend(new MemoryDocumentBackend());
|
|
28
|
+
__setTenantId('tenant-test');
|
|
29
|
+
registerShard(shellShard);
|
|
30
|
+
await activateShard('shell');
|
|
31
|
+
});
|
|
32
|
+
// ── pure text verbs ────────────────────────────────────────────────────
|
|
33
|
+
it('pwd emits a single text entry with the cwd', async () => {
|
|
34
|
+
const out = await runVerbProgrammatic('shell', 'pwd', []);
|
|
35
|
+
expect(out.result).toBeUndefined();
|
|
36
|
+
expect(out.scrollback).toHaveLength(1);
|
|
37
|
+
const entry = out.scrollback[0];
|
|
38
|
+
expect(entry.kind).toBe('text');
|
|
39
|
+
if (entry.kind !== 'text')
|
|
40
|
+
throw new Error('unreachable');
|
|
41
|
+
expect(entry.stream).toBe('stdout');
|
|
42
|
+
expect(entry.chunks.join('')).toMatch(/^\/.*\n$/);
|
|
43
|
+
});
|
|
44
|
+
it('whoami emits a text entry with the user id', async () => {
|
|
45
|
+
const out = await runVerbProgrammatic('shell', 'whoami', []);
|
|
46
|
+
const entry = out.scrollback[0];
|
|
47
|
+
expect(entry.kind).toBe('text');
|
|
48
|
+
if (entry.kind !== 'text')
|
|
49
|
+
throw new Error('unreachable');
|
|
50
|
+
expect(entry.chunks.join('')).toMatch(/guest/);
|
|
51
|
+
});
|
|
52
|
+
// ── rich-entry introspection verbs ─────────────────────────────────────
|
|
53
|
+
it('apps emits a rich entry whose props.data.apps is an array', async () => {
|
|
54
|
+
const out = await runVerbProgrammatic('shell', 'apps', []);
|
|
55
|
+
expect(out.scrollback).toHaveLength(1);
|
|
56
|
+
const entry = out.scrollback[0];
|
|
57
|
+
expect(entry.kind).toBe('rich');
|
|
58
|
+
if (entry.kind !== 'rich')
|
|
59
|
+
throw new Error('unreachable');
|
|
60
|
+
const data = entry.props.data;
|
|
61
|
+
expect(Array.isArray(data.apps)).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
it('apps surfaces every registered app id+label in props.data.apps', async () => {
|
|
64
|
+
registerApp({
|
|
65
|
+
manifest: {
|
|
66
|
+
id: 'demo-one',
|
|
67
|
+
label: 'Demo One',
|
|
68
|
+
version: '0.0.0',
|
|
69
|
+
requiredShards: [],
|
|
70
|
+
layoutVersion: 1,
|
|
71
|
+
},
|
|
72
|
+
initialLayout: [
|
|
73
|
+
{
|
|
74
|
+
name: 'default',
|
|
75
|
+
tree: {
|
|
76
|
+
docked: { type: 'slot', slotId: 'demo-one:s', viewId: 'test:view' },
|
|
77
|
+
floats: [],
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
registerApp({
|
|
83
|
+
manifest: {
|
|
84
|
+
id: 'demo-two',
|
|
85
|
+
label: 'Demo Two',
|
|
86
|
+
version: '0.0.0',
|
|
87
|
+
requiredShards: [],
|
|
88
|
+
layoutVersion: 1,
|
|
89
|
+
},
|
|
90
|
+
initialLayout: [
|
|
91
|
+
{
|
|
92
|
+
name: 'default',
|
|
93
|
+
tree: {
|
|
94
|
+
docked: { type: 'slot', slotId: 'demo-two:s', viewId: 'test:view' },
|
|
95
|
+
floats: [],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
const out = await runVerbProgrammatic('shell', 'apps', []);
|
|
101
|
+
const entry = out.scrollback[0];
|
|
102
|
+
if (entry.kind !== 'rich')
|
|
103
|
+
throw new Error('unreachable');
|
|
104
|
+
const data = entry.props.data;
|
|
105
|
+
expect(data.apps).toEqual(expect.arrayContaining([
|
|
106
|
+
{ id: 'demo-one', label: 'Demo One' },
|
|
107
|
+
{ id: 'demo-two', label: 'Demo Two' },
|
|
108
|
+
]));
|
|
109
|
+
});
|
|
110
|
+
it('app with no active app emits a status entry', async () => {
|
|
111
|
+
const out = await runVerbProgrammatic('shell', 'app', []);
|
|
112
|
+
const entry = out.scrollback[0];
|
|
113
|
+
expect(entry.kind).toBe('status');
|
|
114
|
+
if (entry.kind !== 'status')
|
|
115
|
+
throw new Error('unreachable');
|
|
116
|
+
expect(entry.text).toMatch(/no active app/);
|
|
117
|
+
});
|
|
118
|
+
it('shards emits a rich entry containing the active shell shard', async () => {
|
|
119
|
+
const out = await runVerbProgrammatic('shell', 'shards', []);
|
|
120
|
+
const entry = out.scrollback[0];
|
|
121
|
+
expect(entry.kind).toBe('rich');
|
|
122
|
+
if (entry.kind !== 'rich')
|
|
123
|
+
throw new Error('unreachable');
|
|
124
|
+
const data = entry.props.data;
|
|
125
|
+
expect(data.shards.find((s) => s.id === 'shell')).toBeDefined();
|
|
126
|
+
});
|
|
127
|
+
it('views emits a rich entry whose props.data.views is an array', async () => {
|
|
128
|
+
const out = await runVerbProgrammatic('shell', 'views', []);
|
|
129
|
+
const entry = out.scrollback[0];
|
|
130
|
+
expect(entry.kind).toBe('rich');
|
|
131
|
+
if (entry.kind !== 'rich')
|
|
132
|
+
throw new Error('unreachable');
|
|
133
|
+
const data = entry.props.data;
|
|
134
|
+
expect(Array.isArray(data.views)).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
it('views --standalone lists shell:terminal in a status entry', async () => {
|
|
137
|
+
const out = await runVerbProgrammatic('shell', 'views', ['--standalone']);
|
|
138
|
+
const entry = out.scrollback[0];
|
|
139
|
+
expect(entry.kind).toBe('status');
|
|
140
|
+
if (entry.kind !== 'status')
|
|
141
|
+
throw new Error('unreachable');
|
|
142
|
+
expect(entry.text).toMatch(/shell:terminal/);
|
|
143
|
+
});
|
|
144
|
+
it('zones emits a rich entry whose props.data.rows is an array', async () => {
|
|
145
|
+
const out = await runVerbProgrammatic('shell', 'zones', []);
|
|
146
|
+
const entry = out.scrollback[0];
|
|
147
|
+
expect(entry.kind).toBe('rich');
|
|
148
|
+
if (entry.kind !== 'rich')
|
|
149
|
+
throw new Error('unreachable');
|
|
150
|
+
const data = entry.props.data;
|
|
151
|
+
expect(Array.isArray(data.rows)).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
it('zone with missing args emits a usage status', async () => {
|
|
154
|
+
const out = await runVerbProgrammatic('shell', 'zone', []);
|
|
155
|
+
const entry = out.scrollback[0];
|
|
156
|
+
expect(entry.kind).toBe('status');
|
|
157
|
+
if (entry.kind !== 'status')
|
|
158
|
+
throw new Error('unreachable');
|
|
159
|
+
expect(entry.text).toMatch(/usage/);
|
|
160
|
+
expect(entry.level).toBe('warn');
|
|
161
|
+
});
|
|
162
|
+
it('zone with shardId+zoneName emits a rich entry exposing data.value', async () => {
|
|
163
|
+
const out = await runVerbProgrammatic('shell', 'zone', ['shell', 'workspace']);
|
|
164
|
+
const entry = out.scrollback[0];
|
|
165
|
+
expect(entry.kind).toBe('rich');
|
|
166
|
+
if (entry.kind !== 'rich')
|
|
167
|
+
throw new Error('unreachable');
|
|
168
|
+
expect('value' in entry.props.data).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
// ── imperative layout verbs ────────────────────────────────────────────
|
|
171
|
+
it('open with no args emits a usage warning', async () => {
|
|
172
|
+
const out = await runVerbProgrammatic('shell', 'open', []);
|
|
173
|
+
const entry = out.scrollback[0];
|
|
174
|
+
expect(entry.kind).toBe('status');
|
|
175
|
+
if (entry.kind !== 'status')
|
|
176
|
+
throw new Error('unreachable');
|
|
177
|
+
expect(entry.level).toBe('warn');
|
|
178
|
+
expect(entry.text).toMatch(/usage: open/);
|
|
179
|
+
});
|
|
180
|
+
it('open with unknown viewId emits an error status', async () => {
|
|
181
|
+
const out = await runVerbProgrammatic('shell', 'open', ['no-such-view']);
|
|
182
|
+
const entry = out.scrollback[0];
|
|
183
|
+
expect(entry.kind).toBe('status');
|
|
184
|
+
if (entry.kind !== 'status')
|
|
185
|
+
throw new Error('unreachable');
|
|
186
|
+
expect(entry.level).toBe('error');
|
|
187
|
+
});
|
|
188
|
+
it('close with no args emits a usage warning', async () => {
|
|
189
|
+
const out = await runVerbProgrammatic('shell', 'close', []);
|
|
190
|
+
const entry = out.scrollback[0];
|
|
191
|
+
expect(entry.kind).toBe('status');
|
|
192
|
+
if (entry.kind !== 'status')
|
|
193
|
+
throw new Error('unreachable');
|
|
194
|
+
expect(entry.text).toMatch(/usage: close/);
|
|
195
|
+
});
|
|
196
|
+
it('popout with no args emits a usage warning', async () => {
|
|
197
|
+
const out = await runVerbProgrammatic('shell', 'popout', []);
|
|
198
|
+
const entry = out.scrollback[0];
|
|
199
|
+
expect(entry.kind).toBe('status');
|
|
200
|
+
if (entry.kind !== 'status')
|
|
201
|
+
throw new Error('unreachable');
|
|
202
|
+
expect(entry.text).toMatch(/usage: popout/);
|
|
203
|
+
});
|
|
204
|
+
it('dock with no args reports no active floats', async () => {
|
|
205
|
+
const out = await runVerbProgrammatic('shell', 'dock', []);
|
|
206
|
+
const entry = out.scrollback[0];
|
|
207
|
+
expect(entry.kind).toBe('status');
|
|
208
|
+
if (entry.kind !== 'status')
|
|
209
|
+
throw new Error('unreachable');
|
|
210
|
+
expect(entry.text).toMatch(/no active floats/);
|
|
211
|
+
});
|
|
212
|
+
// ── fs verbs (no server in test → error path) ──────────────────────────
|
|
213
|
+
it('cat with no args emits a missing-argument error', async () => {
|
|
214
|
+
const out = await runVerbProgrammatic('shell', 'cat', []);
|
|
215
|
+
const entry = out.scrollback[0];
|
|
216
|
+
expect(entry.kind).toBe('status');
|
|
217
|
+
if (entry.kind !== 'status')
|
|
218
|
+
throw new Error('unreachable');
|
|
219
|
+
expect(entry.level).toBe('error');
|
|
220
|
+
expect(entry.text).toMatch(/missing file argument/);
|
|
221
|
+
});
|
|
222
|
+
it('ls without an fs backend emits an error status (does not throw)', async () => {
|
|
223
|
+
const out = await runVerbProgrammatic('shell', 'ls', []);
|
|
224
|
+
expect(out.scrollback).toHaveLength(1);
|
|
225
|
+
const entry = out.scrollback[0];
|
|
226
|
+
// Either a real text response (unlikely in test) or an error status —
|
|
227
|
+
// we only commit to the contract that the verb pushes exactly one entry
|
|
228
|
+
// and never throws.
|
|
229
|
+
expect(['text', 'status']).toContain(entry.kind);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ScrollbackEntry } from '../shell-shard/scrollback.svelte';
|
|
2
|
+
export interface RunVerbOpts {
|
|
3
|
+
signal?: AbortSignal;
|
|
4
|
+
structured?: unknown;
|
|
5
|
+
}
|
|
6
|
+
export interface RunVerbResult {
|
|
7
|
+
result: unknown;
|
|
8
|
+
scrollback: ScrollbackEntry[];
|
|
9
|
+
}
|
|
10
|
+
export declare function runVerbProgrammatic(shardId: string, name: string, args: string[], opts?: RunVerbOpts): Promise<RunVerbResult>;
|