sh3-core 0.7.1 → 0.7.5
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/Shell.svelte +3 -2
- package/dist/__test__/fixtures.d.ts +12 -0
- package/dist/__test__/fixtures.js +62 -0
- package/dist/__test__/render.d.ts +3 -0
- package/dist/__test__/render.js +11 -0
- package/dist/__test__/reset.d.ts +14 -0
- package/dist/__test__/reset.js +34 -0
- package/dist/__test__/setup-dom.d.ts +1 -0
- package/dist/__test__/setup-dom.js +26 -0
- package/dist/__test__/smoke.test.d.ts +1 -0
- package/dist/__test__/smoke.test.js +28 -0
- package/dist/api.d.ts +4 -0
- package/dist/apps/lifecycle.js +27 -10
- package/dist/apps/lifecycle.test.d.ts +1 -0
- package/dist/apps/lifecycle.test.js +260 -0
- package/dist/apps/registry.svelte.d.ts +2 -0
- package/dist/apps/registry.svelte.js +5 -0
- package/dist/apps/types.d.ts +17 -0
- package/dist/contract.d.ts +10 -0
- package/dist/contract.js +10 -0
- package/dist/layout/LayoutRenderer.browser.test.d.ts +1 -0
- package/dist/layout/LayoutRenderer.browser.test.js +274 -0
- package/dist/layout/LayoutRenderer.svelte +2 -1
- package/dist/layout/LayoutRenderer.test.d.ts +1 -0
- package/dist/layout/LayoutRenderer.test.js +143 -0
- package/dist/layout/SlotContainer.svelte +8 -2
- package/dist/layout/SlotDropZone.svelte +19 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-1-drag-tab-between-groups-moves-a-tab-from-one-tabs-group-to-another-1.png +0 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-2-drag-tab-to-quadrant-creates-a-split-when-dropping-a-tab-on-a-quadrant-drop-zone-1.png +0 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-3-splitter-drag-updates-split-sizes-when-the-splitter-handle-is-dragged-1.png +0 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-4-close-policy-removes-closable-tabs--keeps-non-closable--and-awaits-canClose-1.png +0 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-5-splitter-collapse-toggle-toggles-collapsed-i--on-double-click-1.png +0 -0
- package/dist/layout/drag.svelte.d.ts +5 -0
- package/dist/layout/drag.svelte.js +15 -0
- package/dist/layout/inspection.js +58 -7
- package/dist/layout/ops.js +25 -6
- package/dist/layout/ops.test.js +51 -1
- package/dist/layout/slotHostPool.svelte.d.ts +16 -1
- package/dist/layout/slotHostPool.svelte.js +124 -6
- package/dist/layout/slotHostPool.test.d.ts +1 -0
- package/dist/layout/slotHostPool.test.js +104 -0
- package/dist/layout/store.svelte.d.ts +22 -0
- package/dist/layout/store.svelte.js +80 -18
- package/dist/layout/tree-walk.d.ts +2 -0
- package/dist/layout/tree-walk.js +1 -1
- package/dist/layout/types.d.ts +5 -0
- package/dist/overlays/FloatFrame.svelte +1 -0
- package/dist/overlays/float.d.ts +2 -0
- package/dist/overlays/float.js +4 -1
- package/dist/overlays/float.test.js +102 -1
- package/dist/primitives/ResizableSplitter.svelte +2 -0
- package/dist/primitives/TabbedPanel.svelte +4 -0
- package/dist/primitives/TabbedPanel.svelte.d.ts +2 -0
- package/dist/shards/activate.svelte.d.ts +6 -0
- package/dist/shards/activate.svelte.js +10 -0
- package/dist/shards/registry.d.ts +4 -0
- package/dist/shards/registry.js +18 -0
- package/dist/shards/types.d.ts +6 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +9 -1
package/dist/Shell.svelte
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import type { OverlayLayer } from './overlays/types';
|
|
21
21
|
import { registerLayerRoot, unregisterLayerRoot } from './overlays/roots';
|
|
22
22
|
import { bindFloatStore, unbindFloatStore, floatManager } from './overlays/float';
|
|
23
|
-
import { returnToHome, isAdmin } from './api';
|
|
23
|
+
import { returnToHome, isAdmin, focusView } from './api';
|
|
24
24
|
import { getActiveRoot, layoutStore } from './layout/store.svelte';
|
|
25
25
|
import { isAuthenticated, isLocalOwner, getUser, logout } from './auth/index';
|
|
26
26
|
import iconsUrl from './assets/icons.svg';
|
|
@@ -82,7 +82,8 @@
|
|
|
82
82
|
) return;
|
|
83
83
|
}
|
|
84
84
|
e.preventDefault();
|
|
85
|
-
|
|
85
|
+
if (!focusView('shell:terminal'))
|
|
86
|
+
floatManager.open('shell:terminal', { title: 'Shell' });
|
|
86
87
|
}
|
|
87
88
|
window.addEventListener('keydown', onKeyDown);
|
|
88
89
|
return () => window.removeEventListener('keydown', onKeyDown);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { App, AppManifest } from '../apps/types';
|
|
2
|
+
import type { Shard, ShardManifest } from '../shards/types';
|
|
3
|
+
import type { LayoutNode, LayoutTree, SplitNode, TabsNode, SlotNode, TabEntry } from '../layout/types';
|
|
4
|
+
export declare function makeAppManifest(overrides?: Partial<AppManifest>): AppManifest;
|
|
5
|
+
export declare function makeApp(overrides?: Partial<App>): App;
|
|
6
|
+
export declare function makeShardManifest(overrides?: Partial<ShardManifest>): ShardManifest;
|
|
7
|
+
export declare function makeShard(overrides?: Partial<Shard>): Shard;
|
|
8
|
+
export declare function makeSlotNode(slotId?: string, viewId?: string): SlotNode;
|
|
9
|
+
export declare function makeTabEntry(overrides?: Partial<TabEntry>): TabEntry;
|
|
10
|
+
export declare function makeTabsNode(tabs?: TabEntry[], overrides?: Partial<TabsNode>): TabsNode;
|
|
11
|
+
export declare function makeSplitNode(children: LayoutNode[], overrides?: Partial<SplitNode>): SplitNode;
|
|
12
|
+
export declare function makeTree(docked: LayoutNode): LayoutTree;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
let seq = 0;
|
|
2
|
+
const uid = (prefix) => `${prefix}-${++seq}`;
|
|
3
|
+
export function makeAppManifest(overrides = {}) {
|
|
4
|
+
var _a, _b, _c, _d, _e;
|
|
5
|
+
return Object.assign({ id: (_a = overrides.id) !== null && _a !== void 0 ? _a : uid('app'), label: (_b = overrides.label) !== null && _b !== void 0 ? _b : 'Test App', version: (_c = overrides.version) !== null && _c !== void 0 ? _c : '1.0.0', requiredShards: (_d = overrides.requiredShards) !== null && _d !== void 0 ? _d : [], layoutVersion: (_e = overrides.layoutVersion) !== null && _e !== void 0 ? _e : 1 }, overrides);
|
|
6
|
+
}
|
|
7
|
+
export function makeApp(overrides = {}) {
|
|
8
|
+
var _a;
|
|
9
|
+
const manifest = makeAppManifest(overrides.manifest);
|
|
10
|
+
return {
|
|
11
|
+
manifest,
|
|
12
|
+
initialLayout: (_a = overrides.initialLayout) !== null && _a !== void 0 ? _a : [
|
|
13
|
+
{ name: 'default', tree: makeTree(makeSlotNode('sh3core.home')) },
|
|
14
|
+
],
|
|
15
|
+
activate: overrides.activate,
|
|
16
|
+
deactivate: overrides.deactivate,
|
|
17
|
+
suspend: overrides.suspend,
|
|
18
|
+
resume: overrides.resume,
|
|
19
|
+
onAppReady: overrides.onAppReady,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function makeShardManifest(overrides = {}) {
|
|
23
|
+
var _a, _b, _c, _d;
|
|
24
|
+
return Object.assign({ id: (_a = overrides.id) !== null && _a !== void 0 ? _a : uid('shard'), label: (_b = overrides.label) !== null && _b !== void 0 ? _b : 'Test Shard', version: (_c = overrides.version) !== null && _c !== void 0 ? _c : '1.0.0', views: (_d = overrides.views) !== null && _d !== void 0 ? _d : [] }, overrides);
|
|
25
|
+
}
|
|
26
|
+
export function makeShard(overrides = {}) {
|
|
27
|
+
var _a;
|
|
28
|
+
return Object.assign({ manifest: makeShardManifest(overrides.manifest), activate: (_a = overrides.activate) !== null && _a !== void 0 ? _a : (() => { }), deactivate: overrides.deactivate }, overrides);
|
|
29
|
+
}
|
|
30
|
+
export function makeSlotNode(slotId, viewId) {
|
|
31
|
+
const id = slotId !== null && slotId !== void 0 ? slotId : uid('slot');
|
|
32
|
+
return { type: 'slot', slotId: id, viewId: viewId !== null && viewId !== void 0 ? viewId : 'test:view' };
|
|
33
|
+
}
|
|
34
|
+
export function makeTabEntry(overrides = {}) {
|
|
35
|
+
var _a, _b, _c;
|
|
36
|
+
const slotId = (_a = overrides.slotId) !== null && _a !== void 0 ? _a : uid('tab');
|
|
37
|
+
return {
|
|
38
|
+
slotId,
|
|
39
|
+
viewId: (_b = overrides.viewId) !== null && _b !== void 0 ? _b : 'test:view',
|
|
40
|
+
label: (_c = overrides.label) !== null && _c !== void 0 ? _c : slotId,
|
|
41
|
+
icon: overrides.icon,
|
|
42
|
+
meta: overrides.meta,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function makeTabsNode(tabs = [makeTabEntry()], overrides = {}) {
|
|
46
|
+
var _a;
|
|
47
|
+
return Object.assign({ type: 'tabs', tabs, activeTab: (_a = overrides.activeTab) !== null && _a !== void 0 ? _a : 0, persistent: overrides.persistent, emptyRenderer: overrides.emptyRenderer }, overrides);
|
|
48
|
+
}
|
|
49
|
+
export function makeSplitNode(children, overrides = {}) {
|
|
50
|
+
var _a, _b;
|
|
51
|
+
return {
|
|
52
|
+
type: 'split',
|
|
53
|
+
direction: (_a = overrides.direction) !== null && _a !== void 0 ? _a : 'horizontal',
|
|
54
|
+
sizes: (_b = overrides.sizes) !== null && _b !== void 0 ? _b : children.map(() => 1 / children.length),
|
|
55
|
+
pinned: overrides.pinned,
|
|
56
|
+
collapsed: overrides.collapsed,
|
|
57
|
+
children,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function makeTree(docked) {
|
|
61
|
+
return { docked, floats: [] };
|
|
62
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { RenderResult } from '@testing-library/svelte';
|
|
2
|
+
import type { Component, ComponentImport } from '@testing-library/svelte-core/types';
|
|
3
|
+
export declare function renderWithShell<C extends Component>(ComponentToRender: ComponentImport<C>, props?: Record<string, unknown>): RenderResult<C>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// packages/sh3-core/src/__test__/render.ts
|
|
2
|
+
import { render } from '@testing-library/svelte';
|
|
3
|
+
export function renderWithShell(ComponentToRender, props = {}) {
|
|
4
|
+
const host = document.createElement('div');
|
|
5
|
+
host.classList.add('sh3-shell-host');
|
|
6
|
+
host.style.position = 'relative';
|
|
7
|
+
host.style.width = '1024px';
|
|
8
|
+
host.style.height = '768px';
|
|
9
|
+
document.body.appendChild(host);
|
|
10
|
+
return render(ComponentToRender, { props, target: host });
|
|
11
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Return the framework to a deterministic boot state for tests.
|
|
3
|
+
*
|
|
4
|
+
* Order rationale:
|
|
5
|
+
* 1. Overlays and presets unbind first — they hold references into the
|
|
6
|
+
* layout store.
|
|
7
|
+
* 2. Drag state is cleared (also removes global pointer listeners).
|
|
8
|
+
* 3. Layout store clears appEntry and root — pool teardown can now run
|
|
9
|
+
* safely.
|
|
10
|
+
* 4. Slot-host pool drops all hosts.
|
|
11
|
+
* 5. View/shard/app registries empty last — shards may have contributed
|
|
12
|
+
* views.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resetFramework(): void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// packages/sh3-core/src/__test__/reset.ts
|
|
2
|
+
// Central reset orchestrator. Every new test file calls this in
|
|
3
|
+
// beforeEach. Order matters — see the in-line comments.
|
|
4
|
+
import { __resetFloatManagerForTest } from '../overlays/float';
|
|
5
|
+
import { __resetPresetManagerForTest } from '../overlays/presets';
|
|
6
|
+
import { __resetDragStateForTest } from '../layout/drag.svelte';
|
|
7
|
+
import { __resetLayoutStoreForTest } from '../layout/store.svelte';
|
|
8
|
+
import { resetSlotHostPool } from '../layout/slotHostPool.svelte';
|
|
9
|
+
import { __resetViewRegistryForTest } from '../shards/registry';
|
|
10
|
+
import { __resetShardRegistryForTest } from '../shards/activate.svelte';
|
|
11
|
+
import { __resetAppRegistryForTest } from '../apps/registry.svelte';
|
|
12
|
+
/**
|
|
13
|
+
* Return the framework to a deterministic boot state for tests.
|
|
14
|
+
*
|
|
15
|
+
* Order rationale:
|
|
16
|
+
* 1. Overlays and presets unbind first — they hold references into the
|
|
17
|
+
* layout store.
|
|
18
|
+
* 2. Drag state is cleared (also removes global pointer listeners).
|
|
19
|
+
* 3. Layout store clears appEntry and root — pool teardown can now run
|
|
20
|
+
* safely.
|
|
21
|
+
* 4. Slot-host pool drops all hosts.
|
|
22
|
+
* 5. View/shard/app registries empty last — shards may have contributed
|
|
23
|
+
* views.
|
|
24
|
+
*/
|
|
25
|
+
export function resetFramework() {
|
|
26
|
+
__resetFloatManagerForTest();
|
|
27
|
+
__resetPresetManagerForTest();
|
|
28
|
+
__resetDragStateForTest();
|
|
29
|
+
__resetLayoutStoreForTest();
|
|
30
|
+
resetSlotHostPool();
|
|
31
|
+
__resetViewRegistryForTest();
|
|
32
|
+
__resetShardRegistryForTest();
|
|
33
|
+
__resetAppRegistryForTest();
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest';
|
|
2
|
+
/**
|
|
3
|
+
* happy-dom creates per-window Comment subclasses (via WindowContextClassExtender)
|
|
4
|
+
* but actually instantiates nodes from the base CommentImplementation class. This
|
|
5
|
+
* breaks Svelte 5's `instanceof Comment` check in `first_child` (operations.js),
|
|
6
|
+
* which it uses to detect and skip Svelte anchor comment nodes in templates.
|
|
7
|
+
*
|
|
8
|
+
* When `instanceof Comment` fails, `first_child` returns the anchor comment itself
|
|
9
|
+
* instead of skipping it, causing template traversal to land on the wrong sibling
|
|
10
|
+
* nodes. This cascades into split-pane snippets receiving a `null` anchor, which
|
|
11
|
+
* triggers Svelte's `invalid_snippet_arguments` error.
|
|
12
|
+
*
|
|
13
|
+
* Fix: override `Symbol.hasInstance` on the per-window `Comment` class so it falls
|
|
14
|
+
* back to a nodeType check (8 = COMMENT_NODE) when the prototype chain check fails.
|
|
15
|
+
* This is safe because nodeType 8 is exclusively Comment nodes in the DOM spec.
|
|
16
|
+
*/
|
|
17
|
+
if (typeof globalThis.Comment === 'function') {
|
|
18
|
+
Object.defineProperty(globalThis.Comment, Symbol.hasInstance, {
|
|
19
|
+
value(instance) {
|
|
20
|
+
if (instance == null)
|
|
21
|
+
return false;
|
|
22
|
+
return instance.nodeType === 8;
|
|
23
|
+
},
|
|
24
|
+
configurable: true,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { resetFramework } from './reset';
|
|
3
|
+
import { registeredApps, activeApp } from '../apps/registry.svelte';
|
|
4
|
+
import { registeredShards, activeShards } from '../shards/activate.svelte';
|
|
5
|
+
import { layoutStore } from '../layout/store.svelte';
|
|
6
|
+
import { makeApp, makeShard } from './fixtures';
|
|
7
|
+
import { registerApp } from '../apps/registry.svelte';
|
|
8
|
+
import { registerShard } from '../shards/activate.svelte';
|
|
9
|
+
describe('resetFramework', () => {
|
|
10
|
+
beforeEach(resetFramework);
|
|
11
|
+
it('returns all registries and the layout store to boot state', () => {
|
|
12
|
+
registerApp(makeApp());
|
|
13
|
+
registerShard(makeShard());
|
|
14
|
+
activeApp.id = 'dirty';
|
|
15
|
+
expect(registeredApps.size).toBeGreaterThan(0);
|
|
16
|
+
expect(registeredShards.size).toBeGreaterThan(0);
|
|
17
|
+
resetFramework();
|
|
18
|
+
expect(registeredApps.size).toBe(0);
|
|
19
|
+
expect(registeredShards.size).toBe(0);
|
|
20
|
+
expect(activeShards.size).toBe(0);
|
|
21
|
+
expect(activeApp.id).toBeNull();
|
|
22
|
+
expect(layoutStore.root).toEqual({
|
|
23
|
+
type: 'slot',
|
|
24
|
+
slotId: 'sh3core.home',
|
|
25
|
+
viewId: 'sh3core:home',
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
package/dist/api.d.ts
CHANGED
|
@@ -3,7 +3,11 @@ export type { Shell } from './shellRuntime.svelte';
|
|
|
3
3
|
export type { Shard, ShardManifest, SourceShard, SourceShardManifest, ShardContext, ViewDeclaration, ViewFactory, ViewHandle, MountContext, } from './shards/types';
|
|
4
4
|
export type { LayoutNode, SplitNode, TabsNode, SlotNode, TabEntry, SplitDirection, SizeMode, LayoutTree, FloatEntry, LayoutPreset, CanonicalPreset, } from './layout/types';
|
|
5
5
|
export type { FloatManager, FloatOptions } from './overlays/float';
|
|
6
|
+
export type { ModalManager } from './overlays/modal';
|
|
7
|
+
export type { PopupManager } from './overlays/popup';
|
|
8
|
+
export type { ToastManager } from './overlays/toast';
|
|
6
9
|
export type { PresetManager } from './overlays/presets';
|
|
10
|
+
export type { OverlayHandle, ModalHandle, ModalOptions, PopupHandle, PopupOptions, PopupPlacement, ToastHandle, ToastOptions, ToastLevel, } from './overlays/types';
|
|
7
11
|
export type { ZoneSchema, ZoneName, ZoneManager } from './state/types';
|
|
8
12
|
export { PERMISSION_STATE_MANAGE } from './state/types';
|
|
9
13
|
export type { StateZones } from './state/zones.svelte';
|
package/dist/apps/lifecycle.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { createStateZones } from '../state/zones.svelte';
|
|
15
15
|
import { activateShard, deactivateShard, getShardContext, registeredShards, } from '../shards/activate.svelte';
|
|
16
|
-
import { attachApp, detachApp, switchToApp, switchToHome, } from '../layout/store.svelte';
|
|
16
|
+
import { attachApp, acquireAppSlotHolds, detachApp, switchToApp, switchToHome, } from '../layout/store.svelte';
|
|
17
17
|
import { activeApp, getRegisteredApp } from './registry.svelte';
|
|
18
18
|
import { createZoneManager } from '../state/manage';
|
|
19
19
|
import { PERMISSION_STATE_MANAGE } from '../state/types';
|
|
@@ -69,7 +69,7 @@ function getOrCreateAppContext(appId) {
|
|
|
69
69
|
* @throws If the app is not registered or a required shard is not registered.
|
|
70
70
|
*/
|
|
71
71
|
export async function launchApp(id) {
|
|
72
|
-
var _a, _b, _c;
|
|
72
|
+
var _a, _b, _c, _d, _e;
|
|
73
73
|
const app = getRegisteredApp(id);
|
|
74
74
|
if (!app) {
|
|
75
75
|
throw new Error(`Cannot launch app "${id}": not registered`);
|
|
@@ -91,25 +91,42 @@ export async function launchApp(id) {
|
|
|
91
91
|
}
|
|
92
92
|
void ((_b = app.resume) === null || _b === void 0 ? void 0 : _b.call(app, getOrCreateAppContext(id)));
|
|
93
93
|
switchToApp();
|
|
94
|
+
void ((_c = app.onAppReady) === null || _c === void 0 ? void 0 : _c.call(app, getOrCreateAppContext(id)));
|
|
94
95
|
writeLastApp(id);
|
|
95
96
|
return;
|
|
96
97
|
}
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// unconditionally.
|
|
98
|
+
// Validate required shards are registered before attaching anything,
|
|
99
|
+
// so we don't half-attach and leave presetManager bound to a blob for
|
|
100
|
+
// an app whose shards we can't even find.
|
|
101
101
|
for (const shardId of app.manifest.requiredShards) {
|
|
102
102
|
if (!registeredShards.has(shardId)) {
|
|
103
103
|
throw new Error(`App "${id}" requires shard "${shardId}" which is not registered`);
|
|
104
104
|
}
|
|
105
|
-
await activateShard(shardId);
|
|
106
105
|
}
|
|
107
|
-
// Attach the layout
|
|
108
|
-
//
|
|
106
|
+
// Attach the layout before activating shards so `presetManager` is
|
|
107
|
+
// bound to this app's AppLayoutBlob during shard activation. Shards
|
|
108
|
+
// legitimately read/switch presets from their activate() hook; the
|
|
109
|
+
// previous order (shards first, attach after) made any such call
|
|
110
|
+
// throw "no app attached". If shard activation fails below, we
|
|
111
|
+
// detach to keep the preset manager state consistent.
|
|
109
112
|
attachApp(app);
|
|
110
|
-
|
|
113
|
+
try {
|
|
114
|
+
for (const shardId of app.manifest.requiredShards) {
|
|
115
|
+
await activateShard(shardId);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
detachApp();
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
// Shards have registered their view factories — safe to take the
|
|
123
|
+
// refcount holds on the app's slots now (pool's factory lookup
|
|
124
|
+
// happens in a microtask from this call).
|
|
125
|
+
acquireAppSlotHolds();
|
|
126
|
+
void ((_d = app.activate) === null || _d === void 0 ? void 0 : _d.call(app, getOrCreateAppContext(id)));
|
|
111
127
|
activeApp.id = id;
|
|
112
128
|
switchToApp();
|
|
129
|
+
void ((_e = app.onAppReady) === null || _e === void 0 ? void 0 : _e.call(app, getOrCreateAppContext(id)));
|
|
113
130
|
writeLastApp(id);
|
|
114
131
|
}
|
|
115
132
|
// ---------- unload --------------------------------------------------------
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { resetFramework } from '../__test__/reset';
|
|
3
|
+
import { makeApp, makeShard, makeAppManifest, makeShardManifest, makeTabsNode, makeTabEntry, makeSlotNode, makeTree, } from '../__test__/fixtures';
|
|
4
|
+
import { launchApp, returnToHome } from './lifecycle';
|
|
5
|
+
import { registerApp } from './registry.svelte';
|
|
6
|
+
import { registerShard } from '../shards/activate.svelte';
|
|
7
|
+
import { presetManager } from '../overlays/presets';
|
|
8
|
+
import { layoutStore } from '../layout/store.svelte';
|
|
9
|
+
import LayoutRenderer from '../layout/LayoutRenderer.svelte';
|
|
10
|
+
import { renderWithShell } from '../__test__/render';
|
|
11
|
+
import { registerView } from '../shards/registry';
|
|
12
|
+
import { tick } from 'svelte';
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Scenario A.1 — step order
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
describe('launchApp — scenario A.1 step order', () => {
|
|
17
|
+
beforeEach(resetFramework);
|
|
18
|
+
it('runs attachApp → activate shards → acquireAppSlotHolds → app.activate → switchToApp → onAppReady', async () => {
|
|
19
|
+
const order = [];
|
|
20
|
+
const shard = makeShard({
|
|
21
|
+
manifest: makeShardManifest({ id: 'shard-A' }),
|
|
22
|
+
activate: () => {
|
|
23
|
+
order.push('shard.activate');
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
registerShard(shard);
|
|
27
|
+
const app = makeApp({
|
|
28
|
+
manifest: makeAppManifest({
|
|
29
|
+
id: 'app-1',
|
|
30
|
+
requiredShards: ['shard-A'],
|
|
31
|
+
}),
|
|
32
|
+
activate: () => {
|
|
33
|
+
order.push('app.activate');
|
|
34
|
+
},
|
|
35
|
+
onAppReady: () => {
|
|
36
|
+
order.push('app.onAppReady');
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
registerApp(app);
|
|
40
|
+
await launchApp('app-1');
|
|
41
|
+
const iShard = order.indexOf('shard.activate');
|
|
42
|
+
const iApp = order.indexOf('app.activate');
|
|
43
|
+
const iReady = order.indexOf('app.onAppReady');
|
|
44
|
+
expect(iShard).toBeGreaterThanOrEqual(0);
|
|
45
|
+
expect(iApp).toBeGreaterThan(iShard);
|
|
46
|
+
expect(iReady).toBeGreaterThan(iApp);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Scenario A.2 — shard activate failure rolls back attach
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
describe('launchApp — scenario A.2 shard failure', () => {
|
|
53
|
+
beforeEach(resetFramework);
|
|
54
|
+
it('detaches the app and re-throws when a required shard throws during activate', async () => {
|
|
55
|
+
const badShard = makeShard({
|
|
56
|
+
manifest: makeShardManifest({ id: 'bad' }),
|
|
57
|
+
activate: () => {
|
|
58
|
+
throw new Error('boom');
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
registerShard(badShard);
|
|
62
|
+
const app = makeApp({
|
|
63
|
+
manifest: makeAppManifest({ id: 'app-2', requiredShards: ['bad'] }),
|
|
64
|
+
});
|
|
65
|
+
registerApp(app);
|
|
66
|
+
const { getAttachedAppId } = await import('../layout/store.svelte');
|
|
67
|
+
await expect(launchApp('app-2')).rejects.toThrow('boom');
|
|
68
|
+
expect(getAttachedAppId()).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Scenario A.3 — re-entry from home uses resume, skips shard re-activation
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
describe('launchApp — scenario A.3 re-entry from home', () => {
|
|
75
|
+
beforeEach(resetFramework);
|
|
76
|
+
it('fires resume and onAppReady on re-entry, does not re-activate shards', async () => {
|
|
77
|
+
const shardActivate = vi.fn();
|
|
78
|
+
const shard = makeShard({
|
|
79
|
+
manifest: makeShardManifest({ id: 'shard-R' }),
|
|
80
|
+
activate: shardActivate,
|
|
81
|
+
});
|
|
82
|
+
registerShard(shard);
|
|
83
|
+
const appResume = vi.fn();
|
|
84
|
+
const appReady = vi.fn();
|
|
85
|
+
const app = makeApp({
|
|
86
|
+
manifest: makeAppManifest({ id: 'app-3', requiredShards: ['shard-R'] }),
|
|
87
|
+
resume: appResume,
|
|
88
|
+
onAppReady: appReady,
|
|
89
|
+
});
|
|
90
|
+
registerApp(app);
|
|
91
|
+
await launchApp('app-3');
|
|
92
|
+
expect(shardActivate).toHaveBeenCalledTimes(1);
|
|
93
|
+
await returnToHome();
|
|
94
|
+
await launchApp('app-3');
|
|
95
|
+
expect(shardActivate).toHaveBeenCalledTimes(1);
|
|
96
|
+
expect(appResume).toHaveBeenCalledTimes(1);
|
|
97
|
+
expect(appReady).toHaveBeenCalledTimes(2);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Scenario A.4 — returnToHome then launch(same) fast path
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
describe('launchApp — scenario A.4 fast path', () => {
|
|
104
|
+
beforeEach(resetFramework);
|
|
105
|
+
it('does not call shard.deactivate or shard.activate when relaunching the same app from home', async () => {
|
|
106
|
+
const shardActivate = vi.fn();
|
|
107
|
+
const shardDeactivate = vi.fn();
|
|
108
|
+
registerShard(makeShard({
|
|
109
|
+
manifest: makeShardManifest({ id: 'shard-F' }),
|
|
110
|
+
activate: shardActivate,
|
|
111
|
+
deactivate: shardDeactivate,
|
|
112
|
+
}));
|
|
113
|
+
registerApp(makeApp({
|
|
114
|
+
manifest: makeAppManifest({ id: 'app-4', requiredShards: ['shard-F'] }),
|
|
115
|
+
}));
|
|
116
|
+
await launchApp('app-4');
|
|
117
|
+
await returnToHome();
|
|
118
|
+
await launchApp('app-4');
|
|
119
|
+
expect(shardActivate).toHaveBeenCalledTimes(1);
|
|
120
|
+
expect(shardDeactivate).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Scenario A.5 — missing required shard fails fast
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
describe('launchApp — scenario A.5 missing shard', () => {
|
|
127
|
+
beforeEach(resetFramework);
|
|
128
|
+
it('throws before attachApp when a required shard is not registered', async () => {
|
|
129
|
+
registerApp(makeApp({
|
|
130
|
+
manifest: makeAppManifest({ id: 'app-5', requiredShards: ['missing'] }),
|
|
131
|
+
}));
|
|
132
|
+
const { getAttachedAppId } = await import('../layout/store.svelte');
|
|
133
|
+
await expect(launchApp('app-5')).rejects.toThrow(/missing/);
|
|
134
|
+
expect(getAttachedAppId()).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Scenario B.1 — presets.switch mutates synchronously
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
describe('presets — scenario B.1 sync mutation', () => {
|
|
141
|
+
beforeEach(resetFramework);
|
|
142
|
+
it('mutates the active-preset blob synchronously on switch', async () => {
|
|
143
|
+
const app = makeApp({
|
|
144
|
+
manifest: makeAppManifest({ id: 'presets-1' }),
|
|
145
|
+
initialLayout: [
|
|
146
|
+
{ name: 'one', tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 'a', label: 'A' })])) },
|
|
147
|
+
{ name: 'two', tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 'b', label: 'B' })])) },
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
registerApp(app);
|
|
151
|
+
await launchApp('presets-1');
|
|
152
|
+
expect(presetManager.active()).toBe('one');
|
|
153
|
+
presetManager.switch('two');
|
|
154
|
+
expect(presetManager.active()).toBe('two');
|
|
155
|
+
expect(layoutStore.root).toMatchObject({ type: 'tabs' });
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Scenario B.2 — shard.activate can call presets.switch
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
describe('presets — scenario B.2 switch from shard.activate', () => {
|
|
162
|
+
beforeEach(resetFramework);
|
|
163
|
+
it('does not throw "no app attached" when a shard calls presets.switch from activate', async () => {
|
|
164
|
+
registerShard(makeShard({
|
|
165
|
+
manifest: makeShardManifest({ id: 'switcher' }),
|
|
166
|
+
activate: () => {
|
|
167
|
+
presetManager.switch('alt');
|
|
168
|
+
},
|
|
169
|
+
}));
|
|
170
|
+
registerApp(makeApp({
|
|
171
|
+
manifest: makeAppManifest({ id: 'presets-2', requiredShards: ['switcher'] }),
|
|
172
|
+
initialLayout: [
|
|
173
|
+
{ name: 'default', tree: makeTree(makeSlotNode('x')) },
|
|
174
|
+
{ name: 'alt', tree: makeTree(makeSlotNode('y')) },
|
|
175
|
+
],
|
|
176
|
+
}));
|
|
177
|
+
await expect(launchApp('presets-2')).resolves.not.toThrow();
|
|
178
|
+
expect(presetManager.active()).toBe('alt');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Scenario B.3 — unknown preset throws
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
describe('presets — scenario B.3 unknown name', () => {
|
|
185
|
+
beforeEach(resetFramework);
|
|
186
|
+
it('throws with a useful message when switching to an unknown preset', async () => {
|
|
187
|
+
registerApp(makeApp({
|
|
188
|
+
manifest: makeAppManifest({ id: 'presets-3' }),
|
|
189
|
+
initialLayout: [{ name: 'only', tree: makeTree(makeSlotNode('x')) }],
|
|
190
|
+
}));
|
|
191
|
+
await launchApp('presets-3');
|
|
192
|
+
expect(() => presetManager.switch('nope')).toThrow(/nope/);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Scenario B.4 — round-trip preserves customization
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
describe('presets — scenario B.4 round-trip preserves customization', () => {
|
|
199
|
+
beforeEach(resetFramework);
|
|
200
|
+
it('preserves per-preset sizes and activeTab across switch A → B → A', async () => {
|
|
201
|
+
var _a;
|
|
202
|
+
registerApp(makeApp({
|
|
203
|
+
manifest: makeAppManifest({ id: 'presets-4' }),
|
|
204
|
+
initialLayout: [
|
|
205
|
+
{
|
|
206
|
+
name: 'A',
|
|
207
|
+
tree: makeTree(makeTabsNode([
|
|
208
|
+
makeTabEntry({ slotId: 't1', label: 'T1' }),
|
|
209
|
+
makeTabEntry({ slotId: 't2', label: 'T2' }),
|
|
210
|
+
])),
|
|
211
|
+
},
|
|
212
|
+
{ name: 'B', tree: makeTree(makeSlotNode('solo')) },
|
|
213
|
+
],
|
|
214
|
+
}));
|
|
215
|
+
await launchApp('presets-4');
|
|
216
|
+
const rootA = layoutStore.root;
|
|
217
|
+
expect(rootA === null || rootA === void 0 ? void 0 : rootA.type).toBe('tabs');
|
|
218
|
+
if ((rootA === null || rootA === void 0 ? void 0 : rootA.type) === 'tabs')
|
|
219
|
+
rootA.activeTab = 1;
|
|
220
|
+
presetManager.switch('B');
|
|
221
|
+
expect((_a = layoutStore.root) === null || _a === void 0 ? void 0 : _a.type).toBe('slot');
|
|
222
|
+
presetManager.switch('A');
|
|
223
|
+
const back = layoutStore.root;
|
|
224
|
+
expect(back === null || back === void 0 ? void 0 : back.type).toBe('tabs');
|
|
225
|
+
if ((back === null || back === void 0 ? void 0 : back.type) === 'tabs')
|
|
226
|
+
expect(back.activeTab).toBe(1);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Scenario B.5 — post-launch switch re-renders (TDD + fix)
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
describe('presets — scenario B.5 post-launch switch re-renders', () => {
|
|
233
|
+
beforeEach(resetFramework);
|
|
234
|
+
it('updates the rendered DOM when presets.switch is called after launchApp', async () => {
|
|
235
|
+
registerView('test:slot-view', {
|
|
236
|
+
mount(container, ctx) {
|
|
237
|
+
const span = document.createElement('span');
|
|
238
|
+
span.dataset.viewFor = ctx.slotId;
|
|
239
|
+
span.textContent = ctx.slotId;
|
|
240
|
+
container.appendChild(span);
|
|
241
|
+
return { unmount: () => span.remove() };
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
registerApp(makeApp({
|
|
245
|
+
manifest: makeAppManifest({ id: 'presets-5' }),
|
|
246
|
+
initialLayout: [
|
|
247
|
+
{ name: 'one', tree: makeTree(makeSlotNode('slot-one', 'test:slot-view')) },
|
|
248
|
+
{ name: 'two', tree: makeTree(makeSlotNode('slot-two', 'test:slot-view')) },
|
|
249
|
+
],
|
|
250
|
+
}));
|
|
251
|
+
await launchApp('presets-5');
|
|
252
|
+
const { container } = renderWithShell(LayoutRenderer, { path: [] });
|
|
253
|
+
await tick();
|
|
254
|
+
expect(container.querySelector('[data-view-for="slot-one"]')).toBeTruthy();
|
|
255
|
+
presetManager.switch('two');
|
|
256
|
+
await tick();
|
|
257
|
+
expect(container.querySelector('[data-view-for="slot-two"]')).toBeTruthy();
|
|
258
|
+
expect(container.querySelector('[data-view-for="slot-one"]')).toBeNull();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -38,3 +38,5 @@ export declare function getActiveApp(): AppManifest | null;
|
|
|
38
38
|
* activate hook. Not re-exported through `api.ts`.
|
|
39
39
|
*/
|
|
40
40
|
export declare function getRegisteredApp(id: string): App | undefined;
|
|
41
|
+
/** Test-only reset: clear registered apps and the active-app pointer. */
|
|
42
|
+
export declare function __resetAppRegistryForTest(): void;
|
|
@@ -57,3 +57,8 @@ export function getActiveApp() {
|
|
|
57
57
|
export function getRegisteredApp(id) {
|
|
58
58
|
return registeredApps.get(id);
|
|
59
59
|
}
|
|
60
|
+
/** Test-only reset: clear registered apps and the active-app pointer. */
|
|
61
|
+
export function __resetAppRegistryForTest() {
|
|
62
|
+
registeredApps.clear();
|
|
63
|
+
activeApp.id = null;
|
|
64
|
+
}
|
package/dist/apps/types.d.ts
CHANGED
|
@@ -96,6 +96,23 @@ export interface App {
|
|
|
96
96
|
* same `AppContext` that `activate` received.
|
|
97
97
|
*/
|
|
98
98
|
resume?(ctx: AppContext): void | Promise<void>;
|
|
99
|
+
/**
|
|
100
|
+
* Called after the framework has switched the rendered root to this
|
|
101
|
+
* app's layout (i.e. `activeLayout()` now returns the app's active
|
|
102
|
+
* preset tree). Fires on both first launch (after `activate`) and
|
|
103
|
+
* on re-entry from home (after `resume`). This is the earliest hook
|
|
104
|
+
* from which layout-mutation APIs like `spliceIntoActiveLayout`,
|
|
105
|
+
* `focusTab`, `dockIntoActiveLayout` reliably target the app's tree.
|
|
106
|
+
*
|
|
107
|
+
* Use this for boot UX that needs to act on the rendered layout —
|
|
108
|
+
* e.g. reopening a last-used document, restoring tab state, or
|
|
109
|
+
* inserting a "welcome" tab into the current preset. Setup that
|
|
110
|
+
* does not touch the rendered layout (registering views, hydrating
|
|
111
|
+
* state, starting bus subscriptions) belongs in `activate`.
|
|
112
|
+
*
|
|
113
|
+
* See ADR-014.
|
|
114
|
+
*/
|
|
115
|
+
onAppReady?(ctx: AppContext): void | Promise<void>;
|
|
99
116
|
}
|
|
100
117
|
/**
|
|
101
118
|
* Source-declared shape of an app manifest — what external package authors
|
package/dist/contract.d.ts
CHANGED
|
@@ -8,12 +8,22 @@ export declare const contract: {
|
|
|
8
8
|
* listed in shardImports or hostImports is illegal. */
|
|
9
9
|
readonly packagePrefix: "sh3-core";
|
|
10
10
|
readonly shard: {
|
|
11
|
+
/** Fields external authors must declare. */
|
|
12
|
+
readonly sourceRequiredFields: readonly ["id", "label", "views"];
|
|
13
|
+
/** Fields the framework stamps at load time — must NOT appear in source. */
|
|
14
|
+
readonly runtimeRequiredFields: readonly ["version"];
|
|
15
|
+
/** Union of both — the full runtime shape. Kept for backward compat. */
|
|
11
16
|
readonly requiredFields: readonly ["id", "label", "version", "views"];
|
|
12
17
|
readonly views: {
|
|
13
18
|
readonly requiredFields: readonly ["id", "label"];
|
|
14
19
|
};
|
|
15
20
|
};
|
|
16
21
|
readonly app: {
|
|
22
|
+
/** Fields external authors must declare. */
|
|
23
|
+
readonly sourceRequiredFields: readonly ["id", "label", "requiredShards", "layoutVersion"];
|
|
24
|
+
/** Fields the framework stamps at load time — must NOT appear in source. */
|
|
25
|
+
readonly runtimeRequiredFields: readonly ["version"];
|
|
26
|
+
/** Union of both — the full runtime shape. Kept for backward compat. */
|
|
17
27
|
readonly requiredFields: readonly ["id", "label", "version", "requiredShards", "layoutVersion"];
|
|
18
28
|
};
|
|
19
29
|
};
|