sh3-core 0.14.3 → 0.15.0
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/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/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +1 -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/shards/activate-contributions.test.js +31 -0
- package/dist/shards/activate-runtime.test.d.ts +1 -0
- package/dist/shards/activate-runtime.test.js +179 -0
- package/dist/shards/activate.svelte.js +20 -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 +38 -1
- 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 +142 -0
- package/dist/shell-shard/shellShard.svelte.d.ts +1 -7
- package/dist/shell-shard/shellShard.svelte.js +8 -163
- package/dist/verbs/types.d.ts +60 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/api.d.ts
CHANGED
|
@@ -46,10 +46,12 @@ export declare const capabilities: {
|
|
|
46
46
|
readonly hotInstall: boolean;
|
|
47
47
|
};
|
|
48
48
|
export type { ServerShard, ServerShardContext, TenantDocumentAPI } from './server-shard/types';
|
|
49
|
-
export type { Verb, VerbContext, ShellApi } from './verbs/types';
|
|
49
|
+
export type { Verb, VerbContext, ShellApi, VerbSchema, PortableJSONSchema, } from './verbs/types';
|
|
50
50
|
export type { Scrollback } from './shell-shard/scrollback.svelte';
|
|
51
51
|
export type { SessionClient } from './shell-shard/session-client.svelte';
|
|
52
52
|
export { listVerbs } from './shards/registry';
|
|
53
|
+
export { runVerbProgrammatic } from './runtime';
|
|
54
|
+
export type { RunVerbOpts, RunVerbResult } from './runtime';
|
|
53
55
|
export { registerShellMode } from './shell-shard/registerShellMode';
|
|
54
56
|
export type { ShellModeDescriptor, ShellModeOutput, ShellModeDispatchHandler, ShellModeDispatchInput, ShellModeRunsOn, RichEntryHandle, StreamHandle, } from './shell-shard/contract';
|
|
55
57
|
export { SHELL_MODE_CONTRIBUTION_POINT } from './shell-shard/contract';
|
package/dist/api.js
CHANGED
|
@@ -56,6 +56,10 @@ export const capabilities = {
|
|
|
56
56
|
hotInstall: typeof Blob !== 'undefined' && typeof URL.createObjectURL === 'function',
|
|
57
57
|
};
|
|
58
58
|
export { listVerbs } from './shards/registry';
|
|
59
|
+
// Programmatic verb dispatch (ctx.runVerb backing function — exposed for
|
|
60
|
+
// advanced use cases like cross-tab dispatch wrappers; most consumers
|
|
61
|
+
// should call ctx.runVerb).
|
|
62
|
+
export { runVerbProgrammatic } from './runtime';
|
|
59
63
|
// Shell mode contributions (external shards extend the shell with new modes).
|
|
60
64
|
export { registerShellMode } from './shell-shard/registerShellMode';
|
|
61
65
|
export { SHELL_MODE_CONTRIBUTION_POINT } from './shell-shard/contract';
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export type { ContributionsApi } from './types';
|
|
2
|
-
export { register, list, listPoints, onChange, __resetContributionsForTest } from './registry';
|
|
2
|
+
export { register, list, listPoints, onChange, onAnyChange, __resetContributionsForTest } from './registry';
|
|
@@ -5,4 +5,4 @@
|
|
|
5
5
|
* file is internal-only, re-exporting the registry for activate.svelte.ts
|
|
6
6
|
* and for tests.
|
|
7
7
|
*/
|
|
8
|
-
export { register, list, listPoints, onChange, __resetContributionsForTest } from './registry';
|
|
8
|
+
export { register, list, listPoints, onChange, onAnyChange, __resetContributionsForTest } from './registry';
|
|
@@ -14,6 +14,13 @@ export declare function listPoints(): string[];
|
|
|
14
14
|
* safe no-op.
|
|
15
15
|
*/
|
|
16
16
|
export declare function onChange(pointId: string, cb: () => void): () => void;
|
|
17
|
+
/**
|
|
18
|
+
* Subscribe to registration changes at every contribution point.
|
|
19
|
+
* The callback receives the `pointId` so consumers can rebuild
|
|
20
|
+
* incrementally. Returns an unsubscribe; double-unsubscribe is a
|
|
21
|
+
* safe no-op. Symmetric with `onChange`, but global.
|
|
22
|
+
*/
|
|
23
|
+
export declare function onAnyChange(cb: (pointId: string) => void): () => void;
|
|
17
24
|
/**
|
|
18
25
|
* Test-only reset. Not exported from the barrel; tests import it
|
|
19
26
|
* directly from this module.
|
|
@@ -12,12 +12,15 @@
|
|
|
12
12
|
*/
|
|
13
13
|
const points = new Map();
|
|
14
14
|
const listeners = new Map();
|
|
15
|
+
const anyListeners = new Set();
|
|
15
16
|
function emit(pointId) {
|
|
16
17
|
const set = listeners.get(pointId);
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
if (set) {
|
|
19
|
+
for (const cb of set)
|
|
20
|
+
cb();
|
|
21
|
+
}
|
|
22
|
+
for (const cb of anyListeners)
|
|
23
|
+
cb(pointId);
|
|
21
24
|
}
|
|
22
25
|
/**
|
|
23
26
|
* Register a descriptor under the given point. Returns an unregister
|
|
@@ -79,6 +82,22 @@ export function onChange(pointId, cb) {
|
|
|
79
82
|
listeners.delete(pointId);
|
|
80
83
|
};
|
|
81
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Subscribe to registration changes at every contribution point.
|
|
87
|
+
* The callback receives the `pointId` so consumers can rebuild
|
|
88
|
+
* incrementally. Returns an unsubscribe; double-unsubscribe is a
|
|
89
|
+
* safe no-op. Symmetric with `onChange`, but global.
|
|
90
|
+
*/
|
|
91
|
+
export function onAnyChange(cb) {
|
|
92
|
+
anyListeners.add(cb);
|
|
93
|
+
let disposed = false;
|
|
94
|
+
return () => {
|
|
95
|
+
if (disposed)
|
|
96
|
+
return;
|
|
97
|
+
disposed = true;
|
|
98
|
+
anyListeners.delete(cb);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
82
101
|
/**
|
|
83
102
|
* Test-only reset. Not exported from the barrel; tests import it
|
|
84
103
|
* directly from this module.
|
|
@@ -86,4 +105,5 @@ export function onChange(pointId, cb) {
|
|
|
86
105
|
export function __resetContributionsForTest() {
|
|
87
106
|
points.clear();
|
|
88
107
|
listeners.clear();
|
|
108
|
+
anyListeners.clear();
|
|
89
109
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { register, list, listPoints, onChange, __resetContributionsForTest, } from './registry';
|
|
2
|
+
import { register, list, listPoints, onChange, onAnyChange, __resetContributionsForTest, } from './registry';
|
|
3
3
|
describe('contributions registry', () => {
|
|
4
4
|
beforeEach(() => {
|
|
5
5
|
__resetContributionsForTest();
|
|
@@ -106,4 +106,59 @@ describe('contributions registry', () => {
|
|
|
106
106
|
expect(cb).not.toHaveBeenCalled();
|
|
107
107
|
});
|
|
108
108
|
});
|
|
109
|
+
describe('onAnyChange', () => {
|
|
110
|
+
it('fires on register at any point', () => {
|
|
111
|
+
const cb = vi.fn();
|
|
112
|
+
onAnyChange(cb);
|
|
113
|
+
register('p1', { id: 'a' });
|
|
114
|
+
register('p2', { id: 'b' });
|
|
115
|
+
expect(cb).toHaveBeenCalledTimes(2);
|
|
116
|
+
expect(cb).toHaveBeenNthCalledWith(1, 'p1');
|
|
117
|
+
expect(cb).toHaveBeenNthCalledWith(2, 'p2');
|
|
118
|
+
});
|
|
119
|
+
it('fires on unregister at any point', () => {
|
|
120
|
+
const cb = vi.fn();
|
|
121
|
+
const u1 = register('p1', { id: 'a' });
|
|
122
|
+
const u2 = register('p2', { id: 'b' });
|
|
123
|
+
onAnyChange(cb);
|
|
124
|
+
u1();
|
|
125
|
+
u2();
|
|
126
|
+
expect(cb).toHaveBeenCalledTimes(2);
|
|
127
|
+
expect(cb).toHaveBeenNthCalledWith(1, 'p1');
|
|
128
|
+
expect(cb).toHaveBeenNthCalledWith(2, 'p2');
|
|
129
|
+
});
|
|
130
|
+
it('supports multiple subscribers', () => {
|
|
131
|
+
const a = vi.fn();
|
|
132
|
+
const b = vi.fn();
|
|
133
|
+
onAnyChange(a);
|
|
134
|
+
onAnyChange(b);
|
|
135
|
+
register('p', { id: 'x' });
|
|
136
|
+
expect(a).toHaveBeenCalledWith('p');
|
|
137
|
+
expect(b).toHaveBeenCalledWith('p');
|
|
138
|
+
});
|
|
139
|
+
it('unsubscribe stops further notifications', () => {
|
|
140
|
+
const cb = vi.fn();
|
|
141
|
+
const off = onAnyChange(cb);
|
|
142
|
+
off();
|
|
143
|
+
register('p', { id: 'x' });
|
|
144
|
+
expect(cb).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
it('double-unsubscribe is a no-op', () => {
|
|
147
|
+
const cb = vi.fn();
|
|
148
|
+
const off = onAnyChange(cb);
|
|
149
|
+
off();
|
|
150
|
+
off();
|
|
151
|
+
register('p', { id: 'x' });
|
|
152
|
+
expect(cb).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
it('coexists with per-point onChange (both fire)', () => {
|
|
155
|
+
const any = vi.fn();
|
|
156
|
+
const point = vi.fn();
|
|
157
|
+
onAnyChange(any);
|
|
158
|
+
onChange('p', point);
|
|
159
|
+
register('p', { id: 'x' });
|
|
160
|
+
expect(any).toHaveBeenCalledWith('p');
|
|
161
|
+
expect(point).toHaveBeenCalledTimes(1);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
109
164
|
});
|
|
@@ -21,4 +21,13 @@ export interface ContributionsApi {
|
|
|
21
21
|
* shard deactivate.
|
|
22
22
|
*/
|
|
23
23
|
onChange(pointId: string, cb: () => void): () => void;
|
|
24
|
+
/**
|
|
25
|
+
* Subscribe to register/unregister at every contribution point. The
|
|
26
|
+
* callback receives the `pointId` so consumers can rebuild incrementally.
|
|
27
|
+
* Returns an unsubscribe; auto-unsubscribed on shard deactivate.
|
|
28
|
+
*
|
|
29
|
+
* Diagnostic-class shards (sh3-ai, sh3-diagnostic) use this to keep a
|
|
30
|
+
* live "everything wired" picture without polling `listPoints`.
|
|
31
|
+
*/
|
|
32
|
+
onAnyChange(cb: (pointId: string) => void): () => void;
|
|
24
33
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { runVerbProgrammatic } from './runVerb';
|
|
@@ -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>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* runVerbProgrammatic — programmatic verb dispatch with synthesized VerbContext.
|
|
3
|
+
*
|
|
4
|
+
* Used by `ctx.runVerb(...)` (see shards/activate.svelte.ts). Builds a
|
|
5
|
+
* sink scrollback that captures entries into an array, a headless ShellApi
|
|
6
|
+
* (no terminal-bound state), a stub SessionClient, and a real TenantFsClient.
|
|
7
|
+
* Verbs that opt in via `programmatic: true` run against this synthesized
|
|
8
|
+
* context; non-programmatic verbs are rejected.
|
|
9
|
+
*
|
|
10
|
+
* Inner `ctx.dispatch(line)` re-enters this function. The inner call
|
|
11
|
+
* shares the same captured scrollback so the outer caller sees a flat
|
|
12
|
+
* transcript; the inner result is discarded (matches terminal `dispatch`
|
|
13
|
+
* fire-and-forget semantics).
|
|
14
|
+
*
|
|
15
|
+
* Resolution: prefixed names (`'sh3-store:install'`) look up directly;
|
|
16
|
+
* unprefixed shell names (`'apps'`) resolve against shardId 'shell'.
|
|
17
|
+
*/
|
|
18
|
+
import { activeShards } from '../shards/activate.svelte';
|
|
19
|
+
import { getVerb, listVerbsWithShard } from '../shards/registry';
|
|
20
|
+
import { makeShellApiHeadless } from '../shell-shard/shellApi';
|
|
21
|
+
import { Scrollback } from '../shell-shard/scrollback.svelte';
|
|
22
|
+
import { SessionClient } from '../shell-shard/session-client.svelte';
|
|
23
|
+
import { TenantFsClient } from '../shell-shard/tenant-fs-client';
|
|
24
|
+
function resolveVerbForDispatch(name) {
|
|
25
|
+
const verb = getVerb(name);
|
|
26
|
+
if (!verb)
|
|
27
|
+
return null;
|
|
28
|
+
const entries = listVerbsWithShard();
|
|
29
|
+
const match = entries.find((e) => e.verb === verb);
|
|
30
|
+
return match ? { verb: match.verb, shardId: match.shardId } : null;
|
|
31
|
+
}
|
|
32
|
+
export async function runVerbProgrammatic(shardId, name, args, opts) {
|
|
33
|
+
if (!activeShards.has(shardId)) {
|
|
34
|
+
throw new Error(`unknown shard: ${shardId}`);
|
|
35
|
+
}
|
|
36
|
+
const verb = getVerb(name);
|
|
37
|
+
if (!verb) {
|
|
38
|
+
throw new Error(`unknown verb: ${name}`);
|
|
39
|
+
}
|
|
40
|
+
if (!verb.programmatic) {
|
|
41
|
+
throw new Error(`verb "${name}" is not programmatic`);
|
|
42
|
+
}
|
|
43
|
+
const captured = [];
|
|
44
|
+
const sinkScrollback = makeSinkScrollback(captured);
|
|
45
|
+
const ctx = await buildProgrammaticContext({
|
|
46
|
+
sinkScrollback,
|
|
47
|
+
captured,
|
|
48
|
+
opts,
|
|
49
|
+
});
|
|
50
|
+
const result = await verb.run(ctx, args);
|
|
51
|
+
return { result, scrollback: captured };
|
|
52
|
+
}
|
|
53
|
+
async function buildProgrammaticContext(b) {
|
|
54
|
+
var _a, _b;
|
|
55
|
+
const ctx = {
|
|
56
|
+
shell: makeShellApiHeadless(),
|
|
57
|
+
scrollback: b.sinkScrollback,
|
|
58
|
+
session: makeStubSession(),
|
|
59
|
+
cwd: '/',
|
|
60
|
+
fs: new TenantFsClient(),
|
|
61
|
+
async dispatch(line) {
|
|
62
|
+
const trimmed = line.trim();
|
|
63
|
+
if (!trimmed)
|
|
64
|
+
return;
|
|
65
|
+
const space = trimmed.indexOf(' ');
|
|
66
|
+
const head = space === -1 ? trimmed : trimmed.slice(0, space);
|
|
67
|
+
const rest = space === -1 ? '' : trimmed.slice(space + 1);
|
|
68
|
+
const parts = rest.length ? rest.split(/\s+/) : [];
|
|
69
|
+
const resolved = resolveVerbForDispatch(head);
|
|
70
|
+
if (!resolved) {
|
|
71
|
+
throw new Error(`dispatch: unknown verb "${head}"`);
|
|
72
|
+
}
|
|
73
|
+
const innerCtx = Object.assign(Object.assign({}, ctx), { structuredArgs: undefined });
|
|
74
|
+
await resolved.verb.run(innerCtx, parts);
|
|
75
|
+
},
|
|
76
|
+
structuredArgs: (_a = b.opts) === null || _a === void 0 ? void 0 : _a.structured,
|
|
77
|
+
signal: (_b = b.opts) === null || _b === void 0 ? void 0 : _b.signal,
|
|
78
|
+
};
|
|
79
|
+
return ctx;
|
|
80
|
+
}
|
|
81
|
+
function makeSinkScrollback(captured) {
|
|
82
|
+
let nextId = 0;
|
|
83
|
+
const sink = {
|
|
84
|
+
entries: captured,
|
|
85
|
+
push(entry) {
|
|
86
|
+
const withId = Object.assign(Object.assign({}, entry), { id: `runverb-${++nextId}` });
|
|
87
|
+
captured.push(withId);
|
|
88
|
+
},
|
|
89
|
+
clear() {
|
|
90
|
+
captured.length = 0;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
return sink;
|
|
94
|
+
}
|
|
95
|
+
function makeStubSession() {
|
|
96
|
+
return new SessionClient('');
|
|
97
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { MemoryDocumentBackend } from '../documents/backends';
|
|
3
|
+
import { __setDocumentBackend, __setTenantId } from '../documents/config';
|
|
4
|
+
import { registerShard, activateShard, __resetShardRegistryForTest, } from '../shards/activate.svelte';
|
|
5
|
+
import { __resetViewRegistryForTest } from '../shards/registry';
|
|
6
|
+
import { runVerbProgrammatic } from './runVerb';
|
|
7
|
+
function makeVerb(name, programmatic, body = async () => undefined) {
|
|
8
|
+
return {
|
|
9
|
+
name,
|
|
10
|
+
summary: `stub ${name}`,
|
|
11
|
+
programmatic: programmatic || undefined,
|
|
12
|
+
async run(ctx, args) {
|
|
13
|
+
await body(ctx, args);
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
describe('runVerbProgrammatic', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
__resetShardRegistryForTest();
|
|
20
|
+
__resetViewRegistryForTest();
|
|
21
|
+
__setDocumentBackend(new MemoryDocumentBackend());
|
|
22
|
+
__setTenantId('tenant-test');
|
|
23
|
+
});
|
|
24
|
+
it('rejects on unknown shard', async () => {
|
|
25
|
+
await expect(runVerbProgrammatic('missing', 'echo', [])).rejects.toThrow('unknown shard: missing');
|
|
26
|
+
});
|
|
27
|
+
it('rejects on unknown verb', async () => {
|
|
28
|
+
registerShard({
|
|
29
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
30
|
+
activate(ctx) {
|
|
31
|
+
ctx.registerVerb(makeVerb('echo', true));
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
await activateShard('tester');
|
|
35
|
+
await expect(runVerbProgrammatic('tester', 'tester:missing', [])).rejects.toThrow(/unknown verb/);
|
|
36
|
+
});
|
|
37
|
+
it('rejects when verb is not programmatic', async () => {
|
|
38
|
+
registerShard({
|
|
39
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
40
|
+
activate(ctx) {
|
|
41
|
+
ctx.registerVerb(makeVerb('plain', false));
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
await activateShard('tester');
|
|
45
|
+
await expect(runVerbProgrammatic('tester', 'tester:plain', [])).rejects.toThrow('verb "tester:plain" is not programmatic');
|
|
46
|
+
});
|
|
47
|
+
it('invokes a programmatic verb and resolves with { result, scrollback }', async () => {
|
|
48
|
+
registerShard({
|
|
49
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
50
|
+
activate(ctx) {
|
|
51
|
+
ctx.registerVerb(makeVerb('echo', true, async (vctx, args) => {
|
|
52
|
+
vctx.scrollback.push({
|
|
53
|
+
kind: 'status',
|
|
54
|
+
text: `echo ${args.join(' ')}`,
|
|
55
|
+
level: 'info',
|
|
56
|
+
ts: 0,
|
|
57
|
+
});
|
|
58
|
+
}));
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
await activateShard('tester');
|
|
62
|
+
const out = await runVerbProgrammatic('tester', 'tester:echo', ['hello', 'world']);
|
|
63
|
+
expect(out.scrollback).toHaveLength(1);
|
|
64
|
+
expect(out.scrollback[0]).toMatchObject({
|
|
65
|
+
kind: 'status',
|
|
66
|
+
text: 'echo hello world',
|
|
67
|
+
level: 'info',
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
it('populates ctx.structuredArgs when opts.structured is set', async () => {
|
|
71
|
+
let observed = undefined;
|
|
72
|
+
registerShard({
|
|
73
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
74
|
+
activate(ctx) {
|
|
75
|
+
ctx.registerVerb(makeVerb('capture', true, async (vctx) => {
|
|
76
|
+
observed = vctx.structuredArgs;
|
|
77
|
+
}));
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
await activateShard('tester');
|
|
81
|
+
await runVerbProgrammatic('tester', 'tester:capture', [], { structured: { foo: 42 } });
|
|
82
|
+
expect(observed).toEqual({ foo: 42 });
|
|
83
|
+
});
|
|
84
|
+
it('plumbs opts.signal onto ctx.signal', async () => {
|
|
85
|
+
let received;
|
|
86
|
+
registerShard({
|
|
87
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
88
|
+
activate(ctx) {
|
|
89
|
+
ctx.registerVerb(makeVerb('peek', true, async (vctx) => {
|
|
90
|
+
received = vctx.signal;
|
|
91
|
+
}));
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
await activateShard('tester');
|
|
95
|
+
const ac = new AbortController();
|
|
96
|
+
await runVerbProgrammatic('tester', 'tester:peek', [], { signal: ac.signal });
|
|
97
|
+
expect(received).toBe(ac.signal);
|
|
98
|
+
});
|
|
99
|
+
it('inner dispatch re-enters runVerb and merges scrollback into the outer capture', async () => {
|
|
100
|
+
registerShard({
|
|
101
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
102
|
+
activate(ctx) {
|
|
103
|
+
ctx.registerVerb(makeVerb('inner', true, async (vctx) => {
|
|
104
|
+
vctx.scrollback.push({ kind: 'status', text: 'inner-fired', level: 'info', ts: 0 });
|
|
105
|
+
}));
|
|
106
|
+
ctx.registerVerb(makeVerb('outer', true, async (vctx) => {
|
|
107
|
+
vctx.scrollback.push({ kind: 'status', text: 'outer-before', level: 'info', ts: 0 });
|
|
108
|
+
await vctx.dispatch('tester:inner');
|
|
109
|
+
vctx.scrollback.push({ kind: 'status', text: 'outer-after', level: 'info', ts: 0 });
|
|
110
|
+
}));
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
await activateShard('tester');
|
|
114
|
+
const out = await runVerbProgrammatic('tester', 'tester:outer', []);
|
|
115
|
+
const texts = out.scrollback
|
|
116
|
+
.filter((e) => e.kind === 'status')
|
|
117
|
+
.map((e) => e.text);
|
|
118
|
+
expect(texts).toEqual(['outer-before', 'inner-fired', 'outer-after']);
|
|
119
|
+
});
|
|
120
|
+
it('propagates an error thrown by the verb', async () => {
|
|
121
|
+
registerShard({
|
|
122
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
123
|
+
activate(ctx) {
|
|
124
|
+
ctx.registerVerb(makeVerb('boom', true, async () => {
|
|
125
|
+
throw new Error('kaboom');
|
|
126
|
+
}));
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
await activateShard('tester');
|
|
130
|
+
await expect(runVerbProgrammatic('tester', 'tester:boom', [])).rejects.toThrow('kaboom');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -107,4 +107,35 @@ describe('ctx.contributions', () => {
|
|
|
107
107
|
// Deactivate must not throw when the entry is already gone.
|
|
108
108
|
expect(() => deactivateShard('p')).not.toThrow();
|
|
109
109
|
});
|
|
110
|
+
it('onAnyChange fires for register/unregister at any point and auto-unsubscribes on deactivate', async () => {
|
|
111
|
+
const cb = vi.fn();
|
|
112
|
+
registerShard({
|
|
113
|
+
manifest: { id: 'watcher', label: 'W', version: '0.0.0', views: [] },
|
|
114
|
+
activate(ctx) {
|
|
115
|
+
ctx.contributions.onAnyChange(cb);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
await activateShard('watcher');
|
|
119
|
+
registerShard({
|
|
120
|
+
manifest: { id: 'p1', label: 'P1', version: '0.0.0', views: [] },
|
|
121
|
+
activate(ctx) {
|
|
122
|
+
ctx.contributions.register('point.a', { id: 'x' });
|
|
123
|
+
ctx.contributions.register('point.b', { id: 'y' });
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
await activateShard('p1');
|
|
127
|
+
expect(cb).toHaveBeenCalledTimes(2);
|
|
128
|
+
expect(cb).toHaveBeenCalledWith('point.a');
|
|
129
|
+
expect(cb).toHaveBeenCalledWith('point.b');
|
|
130
|
+
deactivateShard('watcher');
|
|
131
|
+
cb.mockClear();
|
|
132
|
+
registerShard({
|
|
133
|
+
manifest: { id: 'p2', label: 'P2', version: '0.0.0', views: [] },
|
|
134
|
+
activate(ctx) {
|
|
135
|
+
ctx.contributions.register('point.c', { id: 'z' });
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
await activateShard('p2');
|
|
139
|
+
expect(cb).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
110
141
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { MemoryDocumentBackend } from '../documents/backends';
|
|
3
|
+
import { __setDocumentBackend, __setTenantId } from '../documents/config';
|
|
4
|
+
import { registerShard, activateShard, __resetShardRegistryForTest, } from './activate.svelte';
|
|
5
|
+
import { __resetViewRegistryForTest } from './registry';
|
|
6
|
+
function programmaticVerb(name, summary, body) {
|
|
7
|
+
return {
|
|
8
|
+
name,
|
|
9
|
+
summary,
|
|
10
|
+
programmatic: true,
|
|
11
|
+
async run(vctx, args) {
|
|
12
|
+
if (body)
|
|
13
|
+
await body(vctx, args);
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function plainVerb(name, summary) {
|
|
18
|
+
return { name, summary, async run() { } };
|
|
19
|
+
}
|
|
20
|
+
describe('ctx.listVerbs / ctx.runVerb (integration)', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
__resetShardRegistryForTest();
|
|
23
|
+
__resetViewRegistryForTest();
|
|
24
|
+
__setDocumentBackend(new MemoryDocumentBackend());
|
|
25
|
+
__setTenantId('tenant-test');
|
|
26
|
+
});
|
|
27
|
+
it('listVerbs returns every verb across active shards with shardId', async () => {
|
|
28
|
+
registerShard({
|
|
29
|
+
manifest: { id: 'host', label: 'Host', version: '0.0.0', views: [] },
|
|
30
|
+
activate(ctx) {
|
|
31
|
+
ctx.registerVerb(programmaticVerb('a', 'first verb'));
|
|
32
|
+
ctx.registerVerb(plainVerb('b', 'second verb'));
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
let consumerCtx = null;
|
|
36
|
+
registerShard({
|
|
37
|
+
manifest: { id: 'consumer', label: 'C', version: '0.0.0', views: [] },
|
|
38
|
+
activate(ctx) {
|
|
39
|
+
consumerCtx = ctx;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
await activateShard('host');
|
|
43
|
+
await activateShard('consumer');
|
|
44
|
+
const list = consumerCtx.listVerbs();
|
|
45
|
+
const a = list.find((v) => v.name === 'host:a');
|
|
46
|
+
const b = list.find((v) => v.name === 'host:b');
|
|
47
|
+
expect(a).toEqual({ shardId: 'host', name: 'host:a', summary: 'first verb', schema: undefined });
|
|
48
|
+
expect(b).toEqual({ shardId: 'host', name: 'host:b', summary: 'second verb', schema: undefined });
|
|
49
|
+
});
|
|
50
|
+
it('runVerb dispatches a programmatic verb and returns captured scrollback', async () => {
|
|
51
|
+
registerShard({
|
|
52
|
+
manifest: { id: 'host', label: 'Host', version: '0.0.0', views: [] },
|
|
53
|
+
activate(ctx) {
|
|
54
|
+
ctx.registerVerb(programmaticVerb('echo', 'echoes args', async (vctx, args) => {
|
|
55
|
+
vctx.scrollback.push({ kind: 'status', text: `echo: ${args.join(' ')}`, level: 'info', ts: 0 });
|
|
56
|
+
}));
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
let consumerCtx = null;
|
|
60
|
+
registerShard({
|
|
61
|
+
manifest: { id: 'consumer', label: 'C', version: '0.0.0', views: [] },
|
|
62
|
+
activate(ctx) {
|
|
63
|
+
consumerCtx = ctx;
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
await activateShard('host');
|
|
67
|
+
await activateShard('consumer');
|
|
68
|
+
const out = await consumerCtx.runVerb('host', 'host:echo', ['hello']);
|
|
69
|
+
expect(out.scrollback).toHaveLength(1);
|
|
70
|
+
expect(out.scrollback[0]).toMatchObject({ kind: 'status', text: 'echo: hello' });
|
|
71
|
+
});
|
|
72
|
+
it('runVerb rejects when the target verb is not programmatic', async () => {
|
|
73
|
+
registerShard({
|
|
74
|
+
manifest: { id: 'host', label: 'Host', version: '0.0.0', views: [] },
|
|
75
|
+
activate(ctx) {
|
|
76
|
+
ctx.registerVerb(plainVerb('plain', 'no opt-in'));
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
let consumerCtx = null;
|
|
80
|
+
registerShard({
|
|
81
|
+
manifest: { id: 'consumer', label: 'C', version: '0.0.0', views: [] },
|
|
82
|
+
activate(ctx) {
|
|
83
|
+
consumerCtx = ctx;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
await activateShard('host');
|
|
87
|
+
await activateShard('consumer');
|
|
88
|
+
await expect(consumerCtx.runVerb('host', 'host:plain', [])).rejects.toThrow('verb "host:plain" is not programmatic');
|
|
89
|
+
});
|
|
90
|
+
it('runVerb populates ctx.structuredArgs when opts.structured is set', async () => {
|
|
91
|
+
let observed = undefined;
|
|
92
|
+
registerShard({
|
|
93
|
+
manifest: { id: 'host', label: 'Host', version: '0.0.0', views: [] },
|
|
94
|
+
activate(ctx) {
|
|
95
|
+
ctx.registerVerb(programmaticVerb('schemaCheck', 'reads structuredArgs', async (vctx) => {
|
|
96
|
+
observed = vctx.structuredArgs;
|
|
97
|
+
}));
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
let consumerCtx = null;
|
|
101
|
+
registerShard({
|
|
102
|
+
manifest: { id: 'consumer', label: 'C', version: '0.0.0', views: [] },
|
|
103
|
+
activate(ctx) {
|
|
104
|
+
consumerCtx = ctx;
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
await activateShard('host');
|
|
108
|
+
await activateShard('consumer');
|
|
109
|
+
await consumerCtx.runVerb('host', 'host:schemaCheck', [], { structured: { foo: 'bar' } });
|
|
110
|
+
expect(observed).toEqual({ foo: 'bar' });
|
|
111
|
+
});
|
|
112
|
+
it('runVerb rejects on unknown shardId', async () => {
|
|
113
|
+
let consumerCtx = null;
|
|
114
|
+
registerShard({
|
|
115
|
+
manifest: { id: 'consumer', label: 'C', version: '0.0.0', views: [] },
|
|
116
|
+
activate(ctx) {
|
|
117
|
+
consumerCtx = ctx;
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
await activateShard('consumer');
|
|
121
|
+
await expect(consumerCtx.runVerb('missing', 'x', [])).rejects.toThrow('unknown shard: missing');
|
|
122
|
+
});
|
|
123
|
+
it('runVerb rejects on unknown verb', async () => {
|
|
124
|
+
registerShard({
|
|
125
|
+
manifest: { id: 'host', label: 'Host', version: '0.0.0', views: [] },
|
|
126
|
+
activate(ctx) {
|
|
127
|
+
ctx.registerVerb(programmaticVerb('present', 'exists'));
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
let consumerCtx = null;
|
|
131
|
+
registerShard({
|
|
132
|
+
manifest: { id: 'consumer', label: 'C', version: '0.0.0', views: [] },
|
|
133
|
+
activate(ctx) {
|
|
134
|
+
consumerCtx = ctx;
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
await activateShard('host');
|
|
138
|
+
await activateShard('consumer');
|
|
139
|
+
await expect(consumerCtx.runVerb('host', 'host:absent', [])).rejects.toThrow('unknown verb: host:absent');
|
|
140
|
+
});
|
|
141
|
+
it('verbs declaring schema.input expose it via listVerbs', async () => {
|
|
142
|
+
registerShard({
|
|
143
|
+
manifest: { id: 'host', label: 'Host', version: '0.0.0', views: [] },
|
|
144
|
+
activate(ctx) {
|
|
145
|
+
ctx.registerVerb({
|
|
146
|
+
name: 'typed',
|
|
147
|
+
summary: 'has schema',
|
|
148
|
+
programmatic: true,
|
|
149
|
+
schema: {
|
|
150
|
+
input: {
|
|
151
|
+
type: 'object',
|
|
152
|
+
properties: { msg: { type: 'string', description: 'message' } },
|
|
153
|
+
required: ['msg'],
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
async run() { },
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
let consumerCtx = null;
|
|
161
|
+
registerShard({
|
|
162
|
+
manifest: { id: 'consumer', label: 'C', version: '0.0.0', views: [] },
|
|
163
|
+
activate(ctx) {
|
|
164
|
+
consumerCtx = ctx;
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
await activateShard('host');
|
|
168
|
+
await activateShard('consumer');
|
|
169
|
+
const list = consumerCtx.listVerbs();
|
|
170
|
+
const typed = list.find((v) => v.name === 'host:typed');
|
|
171
|
+
expect(typed === null || typed === void 0 ? void 0 : typed.schema).toEqual({
|
|
172
|
+
input: {
|
|
173
|
+
type: 'object',
|
|
174
|
+
properties: { msg: { type: 'string', description: 'message' } },
|
|
175
|
+
required: ['msg'],
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
});
|