@happyvertical/smrt-svelte 0.43.2 → 0.43.4
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/AGENTS.md +23 -0
- package/README.md +82 -0
- package/dist/Provider.svelte +83 -26
- package/dist/Provider.svelte.d.ts +4 -12
- package/dist/Provider.svelte.d.ts.map +1 -1
- package/dist/__tests__/provider-webmcp-harness.svelte +14 -0
- package/dist/__tests__/provider-webmcp-harness.svelte.d.ts +8 -0
- package/dist/__tests__/provider-webmcp-harness.svelte.d.ts.map +1 -0
- package/dist/components/forms/Form.svelte +112 -35
- package/dist/components/forms/Form.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/Form.webmcp.test.js +5 -5
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/provider-webmcp.test.js +244 -0
- package/dist/web/__tests__/provider-ui-registry.fixture.svelte +43 -0
- package/dist/web/__tests__/provider-ui-registry.fixture.svelte.d.ts +13 -0
- package/dist/web/__tests__/provider-ui-registry.fixture.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/provider-ui-registry.test.js +235 -0
- package/dist/web/__tests__/remote-query-harness.svelte +27 -0
- package/dist/web/__tests__/remote-query-harness.svelte.d.ts +11 -0
- package/dist/web/__tests__/remote-query-harness.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/remote-query.svelte.test.js +100 -0
- package/dist/web/__tests__/webmcp-ui.test.js +410 -0
- package/dist/web/__tests__/webmcp.test.js +1 -1
- package/dist/web/index.d.ts +4 -0
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +3 -0
- package/dist/web/remote-query.svelte.d.ts +26 -0
- package/dist/web/remote-query.svelte.d.ts.map +1 -0
- package/dist/web/remote-query.svelte.js +57 -0
- package/dist/web/webmcp-provider.d.ts +23 -0
- package/dist/web/webmcp-provider.d.ts.map +1 -0
- package/dist/web/webmcp-provider.js +1 -0
- package/dist/web/webmcp-ui-context.d.ts +12 -0
- package/dist/web/webmcp-ui-context.d.ts.map +1 -0
- package/dist/web/webmcp-ui-context.js +16 -0
- package/dist/web/webmcp-ui.d.ts +21 -0
- package/dist/web/webmcp-ui.d.ts.map +1 -0
- package/dist/web/webmcp-ui.js +455 -0
- package/dist/web/webmcp.d.ts +1 -1
- package/dist/web/webmcp.svelte.d.ts +4 -1
- package/dist/web/webmcp.svelte.d.ts.map +1 -1
- package/package.json +5 -5
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { render, waitFor } from '@testing-library/svelte';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { logger } from './internal/logger.js';
|
|
4
|
+
const { registerWebMcpTools } = vi.hoisted(() => ({
|
|
5
|
+
registerWebMcpTools: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
vi.mock('@happyvertical/smrt-web', async (importOriginal) => {
|
|
8
|
+
const actual = await importOriginal();
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
registerWebMcpTools: (...args) => {
|
|
12
|
+
registerWebMcpTools(...args);
|
|
13
|
+
return actual.registerWebMcpTools(...args);
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
import Harness from './__tests__/provider-webmcp-harness.svelte';
|
|
18
|
+
describe('Provider WebMCP policy', () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
registerWebMcpTools.mockClear();
|
|
21
|
+
document.modelContext = undefined;
|
|
22
|
+
});
|
|
23
|
+
it('passes the same exposure policy to the framework-agnostic registrar', async () => {
|
|
24
|
+
const filter = vi.fn(() => true);
|
|
25
|
+
const filterTool = vi.fn(() => true);
|
|
26
|
+
const resolveFetchers = vi.fn();
|
|
27
|
+
const resolveToolFetchers = vi.fn();
|
|
28
|
+
const definitions = [];
|
|
29
|
+
render(Harness, {
|
|
30
|
+
props: {
|
|
31
|
+
webmcp: {
|
|
32
|
+
definitions,
|
|
33
|
+
effects: ['read', 'write'],
|
|
34
|
+
namespace: 'workspace',
|
|
35
|
+
maxTools: 12,
|
|
36
|
+
basePath: '/api/v2',
|
|
37
|
+
scope: 'tenant-a',
|
|
38
|
+
filter,
|
|
39
|
+
filterTool,
|
|
40
|
+
resolveFetchers,
|
|
41
|
+
resolveToolFetchers,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
await waitFor(() => expect(registerWebMcpTools).toHaveBeenCalledOnce());
|
|
46
|
+
expect(registerWebMcpTools).toHaveBeenCalledWith(definitions, {
|
|
47
|
+
client: undefined,
|
|
48
|
+
basePath: '/api/v2',
|
|
49
|
+
fetchFn: undefined,
|
|
50
|
+
scope: 'tenant-a',
|
|
51
|
+
filter,
|
|
52
|
+
filterTool,
|
|
53
|
+
resolveFetchers,
|
|
54
|
+
resolveToolFetchers,
|
|
55
|
+
effects: ['read', 'write'],
|
|
56
|
+
namespace: 'workspace',
|
|
57
|
+
maxTools: 12,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
it('registers the same real tool set as direct registration', async () => {
|
|
61
|
+
const definitions = [
|
|
62
|
+
{
|
|
63
|
+
name: 'reports',
|
|
64
|
+
objectRef: '@test/smrt-svelte:Report',
|
|
65
|
+
className: 'Report',
|
|
66
|
+
endpoint: '/reports',
|
|
67
|
+
idField: 'id',
|
|
68
|
+
actions: ['list', 'create'],
|
|
69
|
+
fields: {},
|
|
70
|
+
toolDescriptors: [
|
|
71
|
+
{
|
|
72
|
+
action: 'list',
|
|
73
|
+
name: 'report_list',
|
|
74
|
+
description: 'List reports',
|
|
75
|
+
inputSchema: { type: 'object' },
|
|
76
|
+
readOnly: true,
|
|
77
|
+
effect: 'read',
|
|
78
|
+
idempotent: true,
|
|
79
|
+
openWorld: false,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
action: 'create',
|
|
83
|
+
name: 'report_create',
|
|
84
|
+
description: 'Create a report',
|
|
85
|
+
inputSchema: { type: 'object' },
|
|
86
|
+
readOnly: false,
|
|
87
|
+
effect: 'write',
|
|
88
|
+
idempotent: false,
|
|
89
|
+
openWorld: false,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
collection: 'audits',
|
|
95
|
+
objectRef: '@test/smrt-svelte:Audit',
|
|
96
|
+
className: 'Audit',
|
|
97
|
+
endpoint: '/audits',
|
|
98
|
+
idField: 'id',
|
|
99
|
+
idType: 'uuid',
|
|
100
|
+
relationships: [],
|
|
101
|
+
action: 'get',
|
|
102
|
+
name: 'audit_get',
|
|
103
|
+
description: 'Get an audit record',
|
|
104
|
+
inputSchema: { type: 'object' },
|
|
105
|
+
readOnly: true,
|
|
106
|
+
effect: 'read',
|
|
107
|
+
idempotent: true,
|
|
108
|
+
openWorld: false,
|
|
109
|
+
route: { method: 'GET', scope: 'item', path: [] },
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
const resolveFetchers = (definition) => {
|
|
113
|
+
definition.actions.length = 0;
|
|
114
|
+
return {
|
|
115
|
+
list: async () => [],
|
|
116
|
+
create: async (data) => data,
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
const resolveToolFetchers = (definition) => {
|
|
120
|
+
definition.action = 'delete';
|
|
121
|
+
return { get: async () => ({ id: 'a1' }) };
|
|
122
|
+
};
|
|
123
|
+
const policy = {
|
|
124
|
+
definitions,
|
|
125
|
+
effects: ['read', 'write'],
|
|
126
|
+
namespace: 'workspace',
|
|
127
|
+
maxTools: 4,
|
|
128
|
+
filter: () => true,
|
|
129
|
+
filterTool: (tool) => tool.collection === 'audits',
|
|
130
|
+
resolveFetchers: vi.fn(resolveFetchers),
|
|
131
|
+
resolveToolFetchers: vi.fn(resolveToolFetchers),
|
|
132
|
+
};
|
|
133
|
+
const captured = [];
|
|
134
|
+
const installRegistry = () => {
|
|
135
|
+
captured.length = 0;
|
|
136
|
+
document.modelContext = {
|
|
137
|
+
async registerTool(tool) {
|
|
138
|
+
captured.push({ name: tool.name, annotations: tool.annotations });
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
installRegistry();
|
|
143
|
+
const { registerWebMcpTools: directRegister } = await vi.importActual('@happyvertical/smrt-web');
|
|
144
|
+
directRegister(definitions, policy);
|
|
145
|
+
const direct = structuredClone(captured);
|
|
146
|
+
installRegistry();
|
|
147
|
+
render(Harness, { props: { webmcp: policy } });
|
|
148
|
+
await waitFor(() => expect(registerWebMcpTools).toHaveBeenCalledOnce());
|
|
149
|
+
expect(captured).toEqual(direct);
|
|
150
|
+
expect(captured.map((tool) => tool.name)).toEqual([
|
|
151
|
+
'workspace_report_list',
|
|
152
|
+
'workspace_report_create',
|
|
153
|
+
'workspace_audit_get',
|
|
154
|
+
]);
|
|
155
|
+
expect(policy.resolveFetchers).toHaveBeenCalledTimes(2);
|
|
156
|
+
expect(policy.resolveToolFetchers).toHaveBeenCalledTimes(2);
|
|
157
|
+
});
|
|
158
|
+
it('defaults generated data tools to read-only exposure', async () => {
|
|
159
|
+
const captured = [];
|
|
160
|
+
document.modelContext = {
|
|
161
|
+
async registerTool(tool) {
|
|
162
|
+
captured.push(tool.name);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
const definitions = [
|
|
166
|
+
{
|
|
167
|
+
name: 'reports',
|
|
168
|
+
objectRef: '@test/smrt-svelte:Report',
|
|
169
|
+
className: 'Report',
|
|
170
|
+
endpoint: '/reports',
|
|
171
|
+
idField: 'id',
|
|
172
|
+
actions: ['list', 'create'],
|
|
173
|
+
fields: {},
|
|
174
|
+
toolDescriptors: [
|
|
175
|
+
{
|
|
176
|
+
action: 'list',
|
|
177
|
+
name: 'report_list',
|
|
178
|
+
description: 'List reports',
|
|
179
|
+
inputSchema: { type: 'object' },
|
|
180
|
+
readOnly: true,
|
|
181
|
+
effect: 'read',
|
|
182
|
+
idempotent: true,
|
|
183
|
+
openWorld: false,
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
action: 'create',
|
|
187
|
+
name: 'report_create',
|
|
188
|
+
description: 'Create a report',
|
|
189
|
+
inputSchema: { type: 'object' },
|
|
190
|
+
readOnly: false,
|
|
191
|
+
effect: 'write',
|
|
192
|
+
idempotent: false,
|
|
193
|
+
openWorld: false,
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
];
|
|
198
|
+
render(Harness, { props: { webmcp: { definitions } } });
|
|
199
|
+
await waitFor(() => expect(registerWebMcpTools).toHaveBeenCalledOnce());
|
|
200
|
+
expect(captured).toEqual(['report_list']);
|
|
201
|
+
});
|
|
202
|
+
it('reports rejected generated data-tool policies without an unhandled rejection', async () => {
|
|
203
|
+
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => { });
|
|
204
|
+
document.modelContext = { registerTool: vi.fn() };
|
|
205
|
+
render(Harness, {
|
|
206
|
+
props: { webmcp: { definitions: [], maxTools: -1 } },
|
|
207
|
+
});
|
|
208
|
+
await waitFor(() => expect(warn).toHaveBeenCalledWith('Provider: WebMCP data tool registration rejected', {
|
|
209
|
+
error: expect.objectContaining({
|
|
210
|
+
message: 'WebMCP maxTools must be a non-negative safe integer',
|
|
211
|
+
}),
|
|
212
|
+
}));
|
|
213
|
+
});
|
|
214
|
+
it('reports asynchronous browser registration rejection', async () => {
|
|
215
|
+
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => { });
|
|
216
|
+
document.modelContext = {
|
|
217
|
+
registerTool: () => Promise.reject(new Error('browser rejected tool')),
|
|
218
|
+
};
|
|
219
|
+
const definitions = [
|
|
220
|
+
{
|
|
221
|
+
collection: 'audits',
|
|
222
|
+
objectRef: '@test/smrt-svelte:Audit',
|
|
223
|
+
className: 'Audit',
|
|
224
|
+
endpoint: '/audits',
|
|
225
|
+
idField: 'id',
|
|
226
|
+
idType: 'uuid',
|
|
227
|
+
relationships: [],
|
|
228
|
+
action: 'get',
|
|
229
|
+
name: 'audit_get',
|
|
230
|
+
description: 'Get an audit record',
|
|
231
|
+
inputSchema: { type: 'object' },
|
|
232
|
+
readOnly: true,
|
|
233
|
+
effect: 'read',
|
|
234
|
+
idempotent: true,
|
|
235
|
+
openWorld: false,
|
|
236
|
+
route: { method: 'GET', scope: 'item', path: [] },
|
|
237
|
+
},
|
|
238
|
+
];
|
|
239
|
+
render(Harness, { props: { webmcp: { definitions } } });
|
|
240
|
+
await waitFor(() => expect(warn).toHaveBeenCalledWith('Provider: WebMCP data tool registration rejected', {
|
|
241
|
+
error: expect.objectContaining({ message: 'browser rejected tool' }),
|
|
242
|
+
}));
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
ControlInteractionEvent,
|
|
4
|
+
ControlInteractionRegistry,
|
|
5
|
+
} from '@happyvertical/smrt-ui/forms';
|
|
6
|
+
import Form from '../../components/forms/Form.svelte';
|
|
7
|
+
import TextInput from '../../components/forms/TextInput.svelte';
|
|
8
|
+
import Provider from '../../Provider.svelte';
|
|
9
|
+
|
|
10
|
+
let {
|
|
11
|
+
providerRegistry,
|
|
12
|
+
explicitRegistry,
|
|
13
|
+
enableUi = true,
|
|
14
|
+
onSharedInteraction,
|
|
15
|
+
onSiblingInteraction,
|
|
16
|
+
showSibling = true,
|
|
17
|
+
}: {
|
|
18
|
+
providerRegistry: ControlInteractionRegistry;
|
|
19
|
+
explicitRegistry: ControlInteractionRegistry;
|
|
20
|
+
enableUi?: boolean;
|
|
21
|
+
onSharedInteraction?: (event: ControlInteractionEvent) => void;
|
|
22
|
+
onSiblingInteraction?: (event: ControlInteractionEvent) => void;
|
|
23
|
+
showSibling?: boolean;
|
|
24
|
+
} = $props();
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<Provider
|
|
28
|
+
webmcp={enableUi
|
|
29
|
+
? { ui: { controlRegistry: providerRegistry } }
|
|
30
|
+
: { definitions: [] }}
|
|
31
|
+
>
|
|
32
|
+
<Form formId="shared-form" oninteraction={onSharedInteraction}>
|
|
33
|
+
<TextInput name="shared-name" label="Shared name" />
|
|
34
|
+
</Form>
|
|
35
|
+
{#if showSibling}
|
|
36
|
+
<Form formId="sibling-form" oninteraction={onSiblingInteraction}>
|
|
37
|
+
<TextInput name="sibling-name" label="Sibling name" />
|
|
38
|
+
</Form>
|
|
39
|
+
{/if}
|
|
40
|
+
<Form formId="explicit-form" interactionRegistry={explicitRegistry}>
|
|
41
|
+
<TextInput name="explicit-name" label="Explicit name" />
|
|
42
|
+
</Form>
|
|
43
|
+
</Provider>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ControlInteractionEvent, ControlInteractionRegistry } from '@happyvertical/smrt-ui/forms';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
providerRegistry: ControlInteractionRegistry;
|
|
4
|
+
explicitRegistry: ControlInteractionRegistry;
|
|
5
|
+
enableUi?: boolean;
|
|
6
|
+
onSharedInteraction?: (event: ControlInteractionEvent) => void;
|
|
7
|
+
onSiblingInteraction?: (event: ControlInteractionEvent) => void;
|
|
8
|
+
showSibling?: boolean;
|
|
9
|
+
};
|
|
10
|
+
declare const ProviderUiRegistry: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
11
|
+
type ProviderUiRegistry = ReturnType<typeof ProviderUiRegistry>;
|
|
12
|
+
export default ProviderUiRegistry;
|
|
13
|
+
//# sourceMappingURL=provider-ui-registry.fixture.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"provider-ui-registry.fixture.svelte.d.ts","sourceRoot":"","sources":["../../../src/web/__tests__/provider-ui-registry.fixture.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,uBAAuB,EACvB,0BAA0B,EAC3B,MAAM,8BAA8B,CAAC;AAKrC,KAAK,gBAAgB,GAAI;IACxB,gBAAgB,EAAE,0BAA0B,CAAC;IAC7C,gBAAgB,EAAE,0BAA0B,CAAC;IAC7C,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mBAAmB,CAAC,EAAE,CAAC,KAAK,EAAE,uBAAuB,KAAK,IAAI,CAAC;IAC/D,oBAAoB,CAAC,EAAE,CAAC,KAAK,EAAE,uBAAuB,KAAK,IAAI,CAAC;IAChE,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,CAAC;AAmCF,QAAA,MAAM,kBAAkB,sDAAwC,CAAC;AACjE,KAAK,kBAAkB,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAChE,eAAe,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { createControlInteractionRegistry } from '@happyvertical/smrt-ui/forms';
|
|
2
|
+
import { render } from '@testing-library/svelte';
|
|
3
|
+
import { tick } from 'svelte';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import Fixture from './provider-ui-registry.fixture.svelte';
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
delete document.modelContext;
|
|
8
|
+
});
|
|
9
|
+
describe('Provider WebMCP UI registry context', () => {
|
|
10
|
+
it('aggregates descendant forms while preserving explicit registry overrides', async () => {
|
|
11
|
+
const providerRegistry = createControlInteractionRegistry();
|
|
12
|
+
const explicitRegistry = createControlInteractionRegistry();
|
|
13
|
+
const signals = [];
|
|
14
|
+
const names = [];
|
|
15
|
+
document.modelContext = {
|
|
16
|
+
async registerTool(tool, options) {
|
|
17
|
+
names.push(tool.name);
|
|
18
|
+
if (options?.signal)
|
|
19
|
+
signals.push(options.signal);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
const view = render(Fixture, {
|
|
23
|
+
props: { providerRegistry, explicitRegistry },
|
|
24
|
+
});
|
|
25
|
+
await tick();
|
|
26
|
+
expect(names).toEqual([
|
|
27
|
+
'smrt_ui_list_form_controls',
|
|
28
|
+
'smrt_ui_inspect_form_control',
|
|
29
|
+
'smrt_ui_execute_form_control',
|
|
30
|
+
'smrt_ui_list_data_surfaces',
|
|
31
|
+
'smrt_ui_inspect_data_surface',
|
|
32
|
+
'smrt_ui_execute_data_surface_control',
|
|
33
|
+
]);
|
|
34
|
+
expect(providerRegistry.list().map((entry) => entry.identity)).toEqual([
|
|
35
|
+
{ formId: 'shared-form', controlId: 'shared-name' },
|
|
36
|
+
{ formId: 'sibling-form', controlId: 'sibling-name' },
|
|
37
|
+
]);
|
|
38
|
+
expect(explicitRegistry.list().map((entry) => entry.identity)).toEqual([
|
|
39
|
+
{ formId: 'explicit-form', controlId: 'explicit-name' },
|
|
40
|
+
]);
|
|
41
|
+
view.unmount();
|
|
42
|
+
expect(providerRegistry.list()).toEqual([]);
|
|
43
|
+
expect(explicitRegistry.list()).toEqual([]);
|
|
44
|
+
expect(signals).toHaveLength(6);
|
|
45
|
+
expect(signals.every((signal) => signal.aborted)).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
it('keeps form interaction callbacks scoped when forms share a Provider registry', async () => {
|
|
48
|
+
const providerRegistry = createControlInteractionRegistry();
|
|
49
|
+
const onSharedInteraction = vi.fn();
|
|
50
|
+
const onSiblingInteraction = vi.fn();
|
|
51
|
+
const view = render(Fixture, {
|
|
52
|
+
props: {
|
|
53
|
+
providerRegistry,
|
|
54
|
+
explicitRegistry: createControlInteractionRegistry(),
|
|
55
|
+
onSharedInteraction,
|
|
56
|
+
onSiblingInteraction,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
await tick();
|
|
60
|
+
onSharedInteraction.mockClear();
|
|
61
|
+
onSiblingInteraction.mockClear();
|
|
62
|
+
await providerRegistry.execute({
|
|
63
|
+
action: 'stage',
|
|
64
|
+
identity: { formId: 'sibling-form', controlId: 'sibling-name' },
|
|
65
|
+
value: 'private sibling value',
|
|
66
|
+
});
|
|
67
|
+
expect(onSharedInteraction).not.toHaveBeenCalled();
|
|
68
|
+
expect(onSiblingInteraction).toHaveBeenCalled();
|
|
69
|
+
expect(onSiblingInteraction.mock.calls.every(([event]) => event.identity.formId === 'sibling-form')).toBe(true);
|
|
70
|
+
view.unmount();
|
|
71
|
+
});
|
|
72
|
+
it('moves mounted controls when the Provider registry changes', async () => {
|
|
73
|
+
const firstRegistry = createControlInteractionRegistry();
|
|
74
|
+
const secondRegistry = createControlInteractionRegistry();
|
|
75
|
+
const explicitRegistry = createControlInteractionRegistry();
|
|
76
|
+
const view = render(Fixture, {
|
|
77
|
+
props: { providerRegistry: firstRegistry, explicitRegistry },
|
|
78
|
+
});
|
|
79
|
+
await tick();
|
|
80
|
+
expect(firstRegistry.list()).toHaveLength(2);
|
|
81
|
+
await view.rerender({
|
|
82
|
+
providerRegistry: secondRegistry,
|
|
83
|
+
explicitRegistry,
|
|
84
|
+
});
|
|
85
|
+
await tick();
|
|
86
|
+
expect(firstRegistry.list()).toEqual([]);
|
|
87
|
+
expect(secondRegistry.list()).toHaveLength(2);
|
|
88
|
+
view.unmount();
|
|
89
|
+
});
|
|
90
|
+
it('preserves staged sibling state when another field unmounts', async () => {
|
|
91
|
+
const providerRegistry = createControlInteractionRegistry();
|
|
92
|
+
const explicitRegistry = createControlInteractionRegistry();
|
|
93
|
+
const view = render(Fixture, {
|
|
94
|
+
props: { providerRegistry, explicitRegistry },
|
|
95
|
+
});
|
|
96
|
+
await tick();
|
|
97
|
+
await providerRegistry.execute({
|
|
98
|
+
action: 'stage',
|
|
99
|
+
identity: { formId: 'shared-form', controlId: 'shared-name' },
|
|
100
|
+
value: 'keep staged',
|
|
101
|
+
});
|
|
102
|
+
await view.rerender({
|
|
103
|
+
providerRegistry,
|
|
104
|
+
explicitRegistry,
|
|
105
|
+
showSibling: false,
|
|
106
|
+
});
|
|
107
|
+
await tick();
|
|
108
|
+
expect(providerRegistry.get({
|
|
109
|
+
formId: 'shared-form',
|
|
110
|
+
controlId: 'shared-name',
|
|
111
|
+
})?.state.stagedValue).toBe('keep staged');
|
|
112
|
+
view.unmount();
|
|
113
|
+
});
|
|
114
|
+
it('retries a shared identity after the existing registration unmounts', async () => {
|
|
115
|
+
const providerRegistry = createControlInteractionRegistry();
|
|
116
|
+
const unregisterExisting = providerRegistry.register({
|
|
117
|
+
identity: { formId: 'shared-form', controlId: 'shared-name' },
|
|
118
|
+
metadata: { kind: 'text', label: 'Existing' },
|
|
119
|
+
getValue: () => 'existing value',
|
|
120
|
+
});
|
|
121
|
+
const view = render(Fixture, {
|
|
122
|
+
props: {
|
|
123
|
+
providerRegistry,
|
|
124
|
+
explicitRegistry: createControlInteractionRegistry(),
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
await tick();
|
|
128
|
+
expect(providerRegistry.get({
|
|
129
|
+
formId: 'shared-form',
|
|
130
|
+
controlId: 'shared-name',
|
|
131
|
+
})?.state.value).toBe('existing value');
|
|
132
|
+
unregisterExisting();
|
|
133
|
+
await tick();
|
|
134
|
+
expect(providerRegistry.get({
|
|
135
|
+
formId: 'shared-form',
|
|
136
|
+
controlId: 'shared-name',
|
|
137
|
+
})?.metadata.label).toBe('Shared name');
|
|
138
|
+
view.unmount();
|
|
139
|
+
expect(providerRegistry.get({
|
|
140
|
+
formId: 'shared-form',
|
|
141
|
+
controlId: 'shared-name',
|
|
142
|
+
})).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
it('recovers after its shared identity is displaced and removed', async () => {
|
|
145
|
+
const providerRegistry = createControlInteractionRegistry();
|
|
146
|
+
const view = render(Fixture, {
|
|
147
|
+
props: {
|
|
148
|
+
providerRegistry,
|
|
149
|
+
explicitRegistry: createControlInteractionRegistry(),
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
await tick();
|
|
153
|
+
const unregisterReplacement = providerRegistry.register({
|
|
154
|
+
identity: { formId: 'shared-form', controlId: 'shared-name' },
|
|
155
|
+
metadata: { kind: 'text', label: 'Replacement' },
|
|
156
|
+
getValue: () => 'replacement value',
|
|
157
|
+
});
|
|
158
|
+
await tick();
|
|
159
|
+
expect(providerRegistry.get({
|
|
160
|
+
formId: 'shared-form',
|
|
161
|
+
controlId: 'shared-name',
|
|
162
|
+
})?.state.value).toBe('replacement value');
|
|
163
|
+
unregisterReplacement();
|
|
164
|
+
await tick();
|
|
165
|
+
expect(providerRegistry.get({
|
|
166
|
+
formId: 'shared-form',
|
|
167
|
+
controlId: 'shared-name',
|
|
168
|
+
})?.metadata.label).toBe('Shared name');
|
|
169
|
+
view.unmount();
|
|
170
|
+
});
|
|
171
|
+
it('contains duplicate Provider prefixes without crashing the app', async () => {
|
|
172
|
+
const names = [];
|
|
173
|
+
document.modelContext = {
|
|
174
|
+
async registerTool(tool) {
|
|
175
|
+
names.push(tool.name);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
const first = render(Fixture, {
|
|
179
|
+
props: {
|
|
180
|
+
providerRegistry: createControlInteractionRegistry(),
|
|
181
|
+
explicitRegistry: createControlInteractionRegistry(),
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
await tick();
|
|
185
|
+
const second = render(Fixture, {
|
|
186
|
+
props: {
|
|
187
|
+
providerRegistry: createControlInteractionRegistry(),
|
|
188
|
+
explicitRegistry: createControlInteractionRegistry(),
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
await tick();
|
|
192
|
+
expect(names).toHaveLength(6);
|
|
193
|
+
second.unmount();
|
|
194
|
+
first.unmount();
|
|
195
|
+
});
|
|
196
|
+
it('isolates throwing interaction observers from registry command outcomes', async () => {
|
|
197
|
+
const providerRegistry = createControlInteractionRegistry();
|
|
198
|
+
const view = render(Fixture, {
|
|
199
|
+
props: {
|
|
200
|
+
providerRegistry,
|
|
201
|
+
explicitRegistry: createControlInteractionRegistry(),
|
|
202
|
+
onSiblingInteraction: () => {
|
|
203
|
+
throw new Error('observer failed');
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
await tick();
|
|
208
|
+
await expect(providerRegistry.execute({
|
|
209
|
+
action: 'stage',
|
|
210
|
+
identity: { formId: 'sibling-form', controlId: 'sibling-name' },
|
|
211
|
+
value: 'still staged',
|
|
212
|
+
})).resolves.toMatchObject({ ok: true, action: 'stage' });
|
|
213
|
+
view.unmount();
|
|
214
|
+
});
|
|
215
|
+
it('preserves legacy object configs that did not opt into UI tools', async () => {
|
|
216
|
+
const names = [];
|
|
217
|
+
const providerRegistry = createControlInteractionRegistry();
|
|
218
|
+
document.modelContext = {
|
|
219
|
+
async registerTool(tool) {
|
|
220
|
+
names.push(tool.name);
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
const view = render(Fixture, {
|
|
224
|
+
props: {
|
|
225
|
+
providerRegistry,
|
|
226
|
+
explicitRegistry: createControlInteractionRegistry(),
|
|
227
|
+
enableUi: false,
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
await tick();
|
|
231
|
+
expect(names).toEqual([]);
|
|
232
|
+
expect(providerRegistry.list()).toEqual([]);
|
|
233
|
+
view.unmount();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
SmrtWebCollection,
|
|
4
|
+
SmrtWebQueryTransport,
|
|
5
|
+
} from '@happyvertical/smrt-web';
|
|
6
|
+
import {
|
|
7
|
+
type RemoteQueryBinding,
|
|
8
|
+
remoteQuery,
|
|
9
|
+
} from '../remote-query.svelte.js';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
collection: SmrtWebCollection<Record<string, unknown>>;
|
|
13
|
+
transport: SmrtWebQueryTransport;
|
|
14
|
+
onReady: (view: RemoteQueryBinding<Record<string, unknown>>) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const props: Props = $props();
|
|
18
|
+
// The harness intentionally creates one binding for its component lifetime.
|
|
19
|
+
// svelte-ignore state_referenced_locally
|
|
20
|
+
const view = remoteQuery(props.collection, props.transport);
|
|
21
|
+
// svelte-ignore state_referenced_locally
|
|
22
|
+
props.onReady(view);
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<output data-testid="remote-query-state">
|
|
26
|
+
{view.request?.requestId ?? 'none'}:{view.rows.map((row) => row.id).join(',')}
|
|
27
|
+
</output>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SmrtWebCollection, SmrtWebQueryTransport } from '@happyvertical/smrt-web';
|
|
2
|
+
import { type RemoteQueryBinding } from '../remote-query.svelte.js';
|
|
3
|
+
interface Props {
|
|
4
|
+
collection: SmrtWebCollection<Record<string, unknown>>;
|
|
5
|
+
transport: SmrtWebQueryTransport;
|
|
6
|
+
onReady: (view: RemoteQueryBinding<Record<string, unknown>>) => void;
|
|
7
|
+
}
|
|
8
|
+
declare const RemoteQueryHarness: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
type RemoteQueryHarness = ReturnType<typeof RemoteQueryHarness>;
|
|
10
|
+
export default RemoteQueryHarness;
|
|
11
|
+
//# sourceMappingURL=remote-query-harness.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"remote-query-harness.svelte.d.ts","sourceRoot":"","sources":["../../../src/web/__tests__/remote-query-harness.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,iBAAiB,EACjB,qBAAqB,EACtB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACL,KAAK,kBAAkB,EAExB,MAAM,2BAA2B,CAAC;AAGnC,UAAU,KAAK;IACb,UAAU,EAAE,iBAAiB,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACvD,SAAS,EAAE,qBAAqB,CAAC;IACjC,OAAO,EAAE,CAAC,IAAI,EAAE,kBAAkB,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,KAAK,IAAI,CAAC;CACtE;AAoBD,QAAA,MAAM,kBAAkB,2CAAwC,CAAC;AACjE,KAAK,kBAAkB,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAChE,eAAe,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createSmrtCollection, } from '@happyvertical/smrt-web';
|
|
2
|
+
import { flushSync, mount, unmount } from 'svelte';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import Harness from './remote-query-harness.svelte';
|
|
5
|
+
const definition = {
|
|
6
|
+
name: 'remote-query-svelte-products',
|
|
7
|
+
className: 'Product',
|
|
8
|
+
endpoint: '/products',
|
|
9
|
+
idField: 'id',
|
|
10
|
+
actions: ['list'],
|
|
11
|
+
fields: { name: { type: 'text' } },
|
|
12
|
+
};
|
|
13
|
+
const request = {
|
|
14
|
+
version: 1,
|
|
15
|
+
requestId: 'svelte-request',
|
|
16
|
+
mode: 'rows',
|
|
17
|
+
page: { kind: 'offset', offset: 0, limit: 10 },
|
|
18
|
+
};
|
|
19
|
+
function envelope(received, name) {
|
|
20
|
+
return {
|
|
21
|
+
version: 1,
|
|
22
|
+
requestId: received.requestId,
|
|
23
|
+
queryFingerprint: 'dq1_svelte_products',
|
|
24
|
+
identityField: 'id',
|
|
25
|
+
rows: [{ id: name, name }],
|
|
26
|
+
page: { kind: 'offset', offset: 0, limit: 10, hasMore: false },
|
|
27
|
+
total: { kind: 'exact', value: 1 },
|
|
28
|
+
freshness: { state: 'fresh' },
|
|
29
|
+
warnings: [],
|
|
30
|
+
truncated: false,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function mountView(collection, transport) {
|
|
34
|
+
const target = document.createElement('div');
|
|
35
|
+
document.body.appendChild(target);
|
|
36
|
+
let view = undefined;
|
|
37
|
+
const component = mount(Harness, {
|
|
38
|
+
target,
|
|
39
|
+
props: {
|
|
40
|
+
collection: collection,
|
|
41
|
+
transport,
|
|
42
|
+
onReady: (next) => {
|
|
43
|
+
view = next;
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
target,
|
|
49
|
+
view,
|
|
50
|
+
teardown: () => {
|
|
51
|
+
unmount(component);
|
|
52
|
+
target.remove();
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
describe('remoteQuery', () => {
|
|
57
|
+
it('renders request and rows reactively through the Svelte binding', async () => {
|
|
58
|
+
const collection = createSmrtCollection(definition, {
|
|
59
|
+
fetchers: { list: async () => [], create: async () => ({}) },
|
|
60
|
+
});
|
|
61
|
+
const mounted = mountView(collection, {
|
|
62
|
+
query: async (received) => envelope(received, 'Ada'),
|
|
63
|
+
});
|
|
64
|
+
await mounted.view.execute(request);
|
|
65
|
+
flushSync();
|
|
66
|
+
expect(mounted.target.textContent).toContain('svelte-request:Ada');
|
|
67
|
+
mounted.teardown();
|
|
68
|
+
await collection.cleanup();
|
|
69
|
+
});
|
|
70
|
+
it('disposes an in-flight refresh and live subscription when unmounted', async () => {
|
|
71
|
+
let calls = 0;
|
|
72
|
+
let unsubscribed = false;
|
|
73
|
+
const collection = createSmrtCollection(definition, {
|
|
74
|
+
fetchers: { list: async () => [], create: async () => ({}) },
|
|
75
|
+
});
|
|
76
|
+
const mounted = mountView(collection, {
|
|
77
|
+
query: async (received, options) => {
|
|
78
|
+
calls += 1;
|
|
79
|
+
if (calls === 1)
|
|
80
|
+
return envelope(received, 'initial');
|
|
81
|
+
return await new Promise((_resolve, reject) => {
|
|
82
|
+
options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true });
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
subscribe: () => ({
|
|
86
|
+
unsubscribe: () => {
|
|
87
|
+
unsubscribed = true;
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
await mounted.view.execute(request);
|
|
92
|
+
mounted.view.subscribeLive();
|
|
93
|
+
const refresh = mounted.view.refresh();
|
|
94
|
+
await vi.waitFor(() => expect(calls).toBe(2));
|
|
95
|
+
mounted.teardown();
|
|
96
|
+
await expect(refresh).rejects.toMatchObject({ name: 'AbortError' });
|
|
97
|
+
expect(unsubscribed).toBe(true);
|
|
98
|
+
await collection.cleanup();
|
|
99
|
+
});
|
|
100
|
+
});
|