janux 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,172 @@
1
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
2
+ import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test';
3
+ import { component, intent, store } from '../define/factories';
4
+ import { jsx } from '../jsx-runtime';
5
+ import { int, list, schema, str, enums } from '../schema';
6
+ import { renderToString } from '../render/server';
7
+ import { boot } from './boot';
8
+
9
+ beforeAll(() => GlobalRegistrator.register({ url: 'http://localhost:3000/' }));
10
+ afterAll(() => GlobalRegistrator.unregister());
11
+
12
+ const counterDetach = mock(() => {});
13
+
14
+ const theme = store({
15
+ name: 'theme',
16
+ state: schema({ mode: enums(['dark', 'light']) }),
17
+ intents: { toggle: intent({ run: ({ state }: any) => (state.mode = 'light') }) },
18
+ });
19
+
20
+ const counter = component({
21
+ name: 'counter',
22
+ state: schema({ n: int() }),
23
+ lifecycle: { detach: counterDetach },
24
+ intents: { inc: intent({ run: ({ state }: any) => (state.n += 1) }) },
25
+ view: ({ state, intents }: any) =>
26
+ jsx('div', { children: [jsx('output', { children: String(state.n) }), jsx('button', { on: intents.inc, children: '+' })] }),
27
+ });
28
+
29
+ const chat = component({
30
+ name: 'chat',
31
+ state: schema({ messages: list({ text: str() }) }),
32
+ intents: {
33
+ add: intent({ input: schema({ text: str() }), run: ({ state, input }: any) => state.messages.push(input) }),
34
+ },
35
+ view: ({ state }: any) => jsx('ul', { children: state.messages.map((m: any, i: number) => jsx('li', { key: String(i), children: m.text })) }),
36
+ });
37
+
38
+ const editorAttach = mock(() => {});
39
+ const editorDetach = mock(() => {});
40
+
41
+ const editor = component({
42
+ name: 'editor',
43
+ lifecycle: { attach: () => editorAttach(), detach: () => editorDetach() },
44
+ intents: {},
45
+ view: () => jsx('div', { class: 'editor', children: 'ready' }),
46
+ });
47
+
48
+ function snapshotScripts(snapshots: any[]): string {
49
+ return snapshots
50
+ .map(
51
+ (s) =>
52
+ `<script type="application/janux+state" data-uri="${s.uri}">${JSON.stringify({ state: s.state, sources: s.sources ?? {} })}</script>`,
53
+ )
54
+ .join('');
55
+ }
56
+
57
+ async function pageHtml(title: string, node: unknown): Promise<string> {
58
+ const { html, snapshots } = await renderToString(node, { storeDefs: { theme } });
59
+
60
+ return `<!doctype html><html><head><title>${title}</title></head><body>${html}${snapshotScripts(snapshots)}</body></html>`;
61
+ }
62
+
63
+ describe('SPA navigation (streamed diff)', () => {
64
+ it('applies the three state rules: app stores live, persist islands live, the rest re-resumes', async () => {
65
+ const pageA = await pageHtml('Page A', [
66
+ jsx('h1', { children: 'A' }),
67
+ jsx(counter as any, {}),
68
+ jsx(chat as any, { persist: true }),
69
+ ]);
70
+ const pageB = await pageHtml('Page B', [
71
+ jsx('h1', { children: 'B' }),
72
+ jsx(chat as any, { persist: true }),
73
+ ]);
74
+
75
+ document.write(pageA);
76
+ document.close();
77
+ expect(document.querySelector('janux-island[data-jx="chat#default"]')!.hasAttribute('data-jx-persist')).toBe(true);
78
+
79
+ const client = boot({ defs: [counter, chat, theme] });
80
+
81
+ // live state before navigating
82
+ await client.call('theme.toggle');
83
+ await client.call('chat.add', { text: 'hello' });
84
+ await client.call('counter.inc');
85
+ await client.settled();
86
+ counterDetach.mockClear();
87
+
88
+ (globalThis as any).fetch = mock(async () => ({
89
+ ok: true,
90
+ text: async () => pageB,
91
+ }));
92
+ await client.navigate('/b');
93
+
94
+ // swapped content + title (whole-document diff)
95
+ expect(document.querySelector('h1')!.textContent).toBe('B');
96
+ expect(document.title).toBe('Page B');
97
+
98
+ // rule 1: app-scope store survives with live state
99
+ expect(((await client.read('store://theme')) as any).state.mode).toBe('light');
100
+
101
+ // rule 2: persisted island keeps its live instance state
102
+ expect(((await client.read('ui://chat')) as any).state.messages).toEqual([{ text: 'hello' }]);
103
+ expect(document.querySelector('janux-island[data-jx="chat#default"] li')!.textContent).toBe('hello');
104
+
105
+ // rule 3: the counter was disposed (detach ran) and is gone from the page
106
+ expect(counterDetach).toHaveBeenCalledTimes(1);
107
+ expect(document.querySelector('janux-island[data-jx="counter#default"]')).toBeNull();
108
+ });
109
+
110
+ it('re-mounts an eager island when its page is revisited after navigating away', async () => {
111
+ const pageEditor = await pageHtml('Editor', [jsx('h1', { children: 'E' }), jsx(editor as any, { eager: true })]);
112
+ const pageDoc = await pageHtml('Doc', [jsx('h1', { children: 'D' })]);
113
+
114
+ document.write(pageEditor);
115
+ document.close();
116
+ editorAttach.mockClear();
117
+ editorDetach.mockClear();
118
+ const client = boot({ defs: [editor] });
119
+
120
+ await client.settled();
121
+ expect(editorAttach).toHaveBeenCalledTimes(1); // first visit mounts
122
+
123
+ // navigate away → the island is gone AND torn down (detach ran)
124
+ (globalThis as any).fetch = mock(async () => ({ ok: true, text: async () => pageDoc }));
125
+ await client.navigate('/doc');
126
+ expect(document.querySelector('janux-island[data-jx="editor#default"]')).toBeNull();
127
+ expect(editorDetach).toHaveBeenCalledTimes(1);
128
+
129
+ // revisit → the eager island mounts again from a clean slate (the playground
130
+ // relies on this attach/detach symmetry to reset Monaco)
131
+ (globalThis as any).fetch = mock(async () => ({ ok: true, text: async () => pageEditor }));
132
+ await client.navigate('/editor');
133
+ expect(document.querySelector('janux-island[data-jx="editor#default"]')).not.toBeNull();
134
+ expect(editorAttach).toHaveBeenCalledTimes(2);
135
+ });
136
+
137
+ it('buffers the response into a single chunk before diffing (deterministic swap)', async () => {
138
+ const pageA = await pageHtml('Page A', jsx('h1', { children: 'A' }));
139
+ const pageB = await pageHtml('Page B', jsx('h1', { children: 'B' }));
140
+ let bodyRead = false;
141
+
142
+ document.write(pageA);
143
+ document.close();
144
+ const client = boot({ defs: [] });
145
+
146
+ (globalThis as any).fetch = mock(async () => ({
147
+ ok: true,
148
+ text: async () => pageB,
149
+ get body() {
150
+ bodyRead = true;
151
+
152
+ return new Response(pageB).body;
153
+ },
154
+ }));
155
+ await client.navigate('/b');
156
+
157
+ expect(document.querySelector('h1')!.textContent).toBe('B');
158
+ expect(bodyRead).toBe(false);
159
+ });
160
+
161
+ it('emits janux:error and hard-navigates when the fetch fails', async () => {
162
+ (globalThis as any).fetch = mock(async () => ({ ok: false, status: 500 }));
163
+ document.write(await pageHtml('Page A', jsx(chat as any, {})));
164
+ document.close();
165
+ const client = boot({ defs: [chat] });
166
+ const errors: string[] = [];
167
+
168
+ document.addEventListener('janux:error', (event: any) => errors.push(event.detail));
169
+ await client.navigate('/broken'); // resolves — the fallback owns the failure
170
+ expect(errors.some((message) => /navigation fetch failed/.test(message))).toBe(true);
171
+ });
172
+ });
@@ -0,0 +1,180 @@
1
+ import diff from 'diff-dom-streaming';
2
+ import { mountIsland, type MountContext } from './mount';
3
+ import { consumePrefetched } from './prefetch';
4
+ import { singleChunkStream } from './single-chunk';
5
+
6
+ export interface NavigateOptions {
7
+ signal?: AbortSignal;
8
+ }
9
+
10
+ const esc = (id: string): string =>
11
+ typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id;
12
+
13
+ function emitNavigate(phase: 'before' | 'after', from: string, to: string): void {
14
+ document.dispatchEvent(new CustomEvent('janux:navigate', { detail: { phase, from, to } }));
15
+ }
16
+
17
+ function throwIfAborted(signal?: AbortSignal): void {
18
+ if (signal?.aborted) throw new DOMException('navigation superseded', 'AbortError');
19
+ }
20
+
21
+ interface PersistedIsland {
22
+ id: string;
23
+ node: Element;
24
+ }
25
+
26
+ /**
27
+ * transition:persist strategy: mounted `persist` islands are lifted out of the
28
+ * document before the diff (live instance and DOM untouched) and grafted back
29
+ * over whatever the incoming page rendered for them — or disposed if it's gone.
30
+ */
31
+ function extractPersisted(mount: MountContext): PersistedIsland[] {
32
+ return [...document.querySelectorAll('janux-island[data-jx-persist]')]
33
+ .filter((node) => mount.registry.mounted.has(node.getAttribute('data-jx') ?? ''))
34
+ .map((node) => {
35
+ node.remove();
36
+
37
+ return { id: node.getAttribute('data-jx')!, node };
38
+ });
39
+ }
40
+
41
+ async function restorePersisted(mount: MountContext, kept: PersistedIsland[]): Promise<void> {
42
+ await Promise.all(
43
+ kept.map(async ({ id, node }) => {
44
+ const incoming = document.querySelector(`janux-island[data-jx="${esc(id)}"]`);
45
+
46
+ if (incoming) incoming.replaceWith(node);
47
+ else await mount.registry.mounted.get(id)?.dispose();
48
+ }),
49
+ );
50
+ }
51
+
52
+ /** Store names still referenced by an island that survived the diff (persisted or newly resumed). */
53
+ function storesInUse(mount: MountContext): Set<string> {
54
+ const used = [...mount.registry.mounted.values()].flatMap((instance) =>
55
+ Object.values(instance.def.use ?? {}).map((store) => store.name),
56
+ );
57
+
58
+ return new Set(used);
59
+ }
60
+
61
+ /** After the diff: dispose route stores no surviving island still uses. Islands are swept by DOM state. */
62
+ async function disposeRouteStores(mount: MountContext): Promise<void> {
63
+ const { registry } = mount;
64
+ const keptStores = storesInUse(mount);
65
+ const dropped = [...registry.stores.entries()].filter(
66
+ ([name, instance]) => instance.def.scope === 'route' && !keptStores.has(name),
67
+ );
68
+
69
+ await Promise.all(dropped.map(([, instance]) => instance.dispose()));
70
+ dropped.forEach(([name]) => registry.stores.delete(name));
71
+ }
72
+
73
+ /** App-store snapshots (already-embedded) survive; UI snapshots are replaced by the incoming page. */
74
+ function reindexSnapshots(mount: MountContext): void {
75
+ [...mount.registry.snapshots.keys()]
76
+ .filter((uri) => uri.startsWith('ui://'))
77
+ .forEach((uri) => mount.registry.snapshots.delete(uri));
78
+ document.querySelectorAll('script[type="application/janux+state"]').forEach((script) => {
79
+ const uri = script.getAttribute('data-uri');
80
+
81
+ if (!uri) return;
82
+ try {
83
+ mount.registry.snapshots.set(uri, JSON.parse(script.textContent ?? '{}'));
84
+ } catch {
85
+ document.dispatchEvent(new CustomEvent('janux:error', { detail: `invalid snapshot ${uri}` }));
86
+ }
87
+ });
88
+ }
89
+
90
+ /** Islands marked `eager` mount without waiting for interaction (editors, event listeners…). */
91
+ export async function mountEagerIslands(mount: MountContext): Promise<void> {
92
+ const pending = [...document.querySelectorAll('janux-island[data-jx-eager]')].filter(
93
+ (node) => !mount.registry.mounted.has(node.getAttribute('data-jx') ?? ''),
94
+ );
95
+
96
+ await Promise.all(pending.map((node) => mountIsland(node.getAttribute('data-jx')!, node, mount)));
97
+ }
98
+
99
+ async function sweepDisconnected(mount: MountContext): Promise<void> {
100
+ const gone = [...mount.registry.mounted.entries()].filter(
101
+ ([id]) => !document.querySelector(`janux-island[data-jx="${esc(id)}"]`)?.isConnected,
102
+ );
103
+
104
+ await Promise.all(gone.map(([, instance]) => instance.dispose()));
105
+ }
106
+
107
+ async function fetchStream(url: string, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>> {
108
+ const cached = consumePrefetched(url);
109
+
110
+ if (cached) return cached;
111
+ const response = await fetch(url, { signal, headers: { accept: 'text/html' } });
112
+
113
+ if (!response.ok) throw new Error(`navigation fetch failed (${response.status})`);
114
+
115
+ // Buffer the whole page before diffing: a navigation fetches a complete,
116
+ // server-rendered page, so a single-chunk stream is deterministic and
117
+ // avoids the streaming diff's chunk-boundary edge cases (a swap is not SSR).
118
+ return singleChunkStream(await response.text());
119
+ }
120
+
121
+ /**
122
+ * Diff-then-dispose: nothing is destroyed before the DOM actually changes, so
123
+ * an abort before/during the diff leaves the current page fully intact (kept
124
+ * persisted nodes are re-attached, no island disposed). Disposal happens after,
125
+ * driven by what the diff removed from the document.
126
+ */
127
+ async function applyPage(mount: MountContext, stream: ReadableStream<Uint8Array>, signal?: AbortSignal): Promise<void> {
128
+ const kept = extractPersisted(mount);
129
+
130
+ try {
131
+ throwIfAborted(signal);
132
+ // The Navigation API drives the transition; diff directly (its own would be skipped).
133
+ await diff(document, stream);
134
+ await restorePersisted(mount, kept);
135
+ } catch (error) {
136
+ // Aborted/failed before the swap committed: put persisted nodes back untouched.
137
+ kept.forEach(({ node }) => {
138
+ if (!node.isConnected) document.body.appendChild(node);
139
+ });
140
+ throw error;
141
+ }
142
+ }
143
+
144
+ async function runNavigation(url: string, mount: MountContext, options: NavigateOptions): Promise<void> {
145
+ const from = location.href;
146
+
147
+ emitNavigate('before', from, url);
148
+ try {
149
+ throwIfAborted(options.signal);
150
+ const stream = await fetchStream(url, options.signal);
151
+
152
+ throwIfAborted(options.signal);
153
+ await applyPage(mount, stream, options.signal);
154
+ reindexSnapshots(mount);
155
+ await sweepDisconnected(mount);
156
+ await disposeRouteStores(mount);
157
+ await mountEagerIslands(mount);
158
+ emitNavigate('after', from, url);
159
+ } catch (error) {
160
+ if ((error as any)?.name === 'AbortError') return;
161
+ document.dispatchEvent(new CustomEvent('janux:error', { detail: String(error) }));
162
+ location.href = url;
163
+ }
164
+ }
165
+
166
+ let navChain: Promise<void> = Promise.resolve();
167
+
168
+ /**
169
+ * SPA navigation: streams the next page's HTML and DIFFS it against the live
170
+ * document (diff-dom-streaming) — unchanged shells are never touched, persisted
171
+ * islands keep their live instances, and everything else re-resumes from the
172
+ * incoming snapshots, exactly like an initial load. Navigations are serialized:
173
+ * a superseded one (aborted by the Navigation API) finishes its cleanup before
174
+ * the next starts, so persisted islands are never lost to a race.
175
+ */
176
+ export function performNavigation(url: string, mount: MountContext, options: NavigateOptions = {}): Promise<void> {
177
+ navChain = navChain.then(() => runNavigation(url, mount, options));
178
+
179
+ return navChain;
180
+ }
@@ -0,0 +1,38 @@
1
+ interface PrefetchEntry {
2
+ body: Promise<string>;
3
+ at: number;
4
+ }
5
+
6
+ const prefetched = new Map<string, PrefetchEntry>();
7
+ const PREFETCH_TTL = 30_000;
8
+
9
+ function isFresh(entry: PrefetchEntry | undefined): entry is PrefetchEntry {
10
+ return entry !== undefined && Date.now() - entry.at <= PREFETCH_TTL;
11
+ }
12
+
13
+ /** Warms the next page on link hover; entries expire after 30s. */
14
+ export function prefetch(url: string): void {
15
+ if (isFresh(prefetched.get(url))) return;
16
+ prefetched.set(url, {
17
+ at: Date.now(),
18
+ body: fetch(url, { headers: { accept: 'text/html' } }).then((response) =>
19
+ response.ok ? response.text() : Promise.reject(new Error('prefetch failed')),
20
+ ),
21
+ });
22
+ prefetched.get(url)!.body.catch(() => prefetched.delete(url));
23
+ }
24
+
25
+ /** Returns a stream of the prefetched page if still fresh, evicting the entry either way. */
26
+ export function consumePrefetched(url: string): ReadableStream<Uint8Array> | undefined {
27
+ const entry = prefetched.get(url);
28
+
29
+ prefetched.delete(url);
30
+ if (!isFresh(entry)) return undefined;
31
+
32
+ return new ReadableStream({
33
+ async start(controller) {
34
+ controller.enqueue(new TextEncoder().encode(await entry.body));
35
+ controller.close();
36
+ },
37
+ });
38
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Wraps already-buffered HTML into a one-chunk stream. Client navigation
3
+ * fetches a complete, server-rendered page, so streaming buys nothing here —
4
+ * and delivering it in a single chunk sidesteps the diff's chunk-boundary
5
+ * edge cases entirely, making every navigation deterministic.
6
+ */
7
+ export function singleChunkStream(html: string): ReadableStream<Uint8Array> {
8
+ return new ReadableStream({
9
+ start(controller) {
10
+ controller.enqueue(new TextEncoder().encode(html));
11
+ controller.close();
12
+ },
13
+ });
14
+ }
@@ -0,0 +1,222 @@
1
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
2
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
3
+ import { component, intent } from '../define/factories';
4
+ import { jsx } from '../jsx-runtime';
5
+ import { int, schema } from '../schema';
6
+ import { renderToString } from '../render/server';
7
+ import { boot, type JanuxClient } from './boot';
8
+ import {
9
+ createModelContextPolyfill,
10
+ installWebMCP,
11
+ type ModelContextPolyfill,
12
+ type WebMCPHandle,
13
+ } from './webmcp';
14
+
15
+ beforeAll(() => GlobalRegistrator.register());
16
+ afterAll(() => GlobalRegistrator.unregister());
17
+
18
+ const counter = component({
19
+ name: 'counter',
20
+ state: schema({ n: int() }),
21
+ intents: {
22
+ inc: intent({ description: 'Increment', run: ({ state }) => (state.n += 1) }),
23
+ reset: intent({ guard: 'confirm', run: ({ state }) => (state.n = 0) }),
24
+ },
25
+ view: ({ state, intents }: any) =>
26
+ jsx('div', {
27
+ children: [
28
+ jsx('output', { children: `n=${state.n}` }),
29
+ jsx('button', { on: intents.inc, children: '+1' }),
30
+ ],
31
+ }),
32
+ });
33
+
34
+ const ROUTE_MANIFEST = {
35
+ janux: '0.1.0',
36
+ resources: [],
37
+ tools: [
38
+ {
39
+ name: 'api.shop.pay',
40
+ description: 'Pay the order',
41
+ guard: 'confirm',
42
+ input: { type: 'object', properties: { total: { type: 'number' } } },
43
+ },
44
+ ],
45
+ events: [],
46
+ };
47
+
48
+ const originalFetch = globalThis.fetch;
49
+ let manifestTools = ROUTE_MANIFEST.tools;
50
+ const fetchMock = mock(async (input: any, init?: RequestInit) => {
51
+ const url = String(input);
52
+
53
+ if (url.startsWith('/_janux/manifest')) {
54
+ return Response.json({ ...ROUTE_MANIFEST, tools: manifestTools });
55
+ }
56
+ if (url.startsWith('/_janux/api/')) return Response.json({ ok: true, result: 'paid' });
57
+
58
+ return originalFetch(input, init);
59
+ });
60
+
61
+ async function serveAndBoot(): Promise<JanuxClient> {
62
+ const { html, snapshots } = await renderToString(jsx(counter as any, {}), {
63
+ initialState: { 'ui://counter#default': { n: 5 } },
64
+ });
65
+ const scripts = snapshots
66
+ .map(
67
+ (s) =>
68
+ `<script type="application/janux+state" data-uri="${s.uri}">${JSON.stringify({ state: s.state, sources: s.sources ?? {} })}</script>`,
69
+ )
70
+ .join('');
71
+
72
+ document.body.innerHTML = html + scripts;
73
+
74
+ return boot({ defs: [counter], webmcp: false });
75
+ }
76
+
77
+ function polyfillOf(): ModelContextPolyfill {
78
+ return (document as any).modelContext;
79
+ }
80
+
81
+ describe('WebMCP integration', () => {
82
+ beforeEach(() => {
83
+ document.body.innerHTML = '';
84
+ delete (document as any).modelContext;
85
+ manifestTools = ROUTE_MANIFEST.tools;
86
+ fetchMock.mockClear();
87
+ globalThis.fetch = fetchMock as any;
88
+ });
89
+
90
+ afterEach(() => {
91
+ globalThis.fetch = originalFetch;
92
+ });
93
+
94
+ it('polyfill: registers, lists, calls and honors AbortSignal', async () => {
95
+ const context = createModelContextPolyfill();
96
+ const controller = new AbortController();
97
+
98
+ context.registerTool(
99
+ { name: 'echo', description: 'Echo', execute: (input) => input },
100
+ { signal: controller.signal },
101
+ );
102
+ expect(context.listTools().map((tool) => tool.name)).toEqual(['echo']);
103
+ expect(await context.callTool('echo', { a: 1 })).toEqual({ a: 1 });
104
+
105
+ controller.abort();
106
+ expect(context.listTools()).toHaveLength(0);
107
+ expect(context.callTool('echo')).rejects.toThrow('unknown tool');
108
+ });
109
+
110
+ it('polyfill: provideContext replaces the registered set', () => {
111
+ const context = createModelContextPolyfill();
112
+
113
+ context.registerTool({ name: 'old', execute: () => null });
114
+ context.provideContext!({ tools: [{ name: 'next', execute: () => null }] });
115
+ expect(context.listTools().map((tool) => tool.name)).toEqual(['next']);
116
+ });
117
+
118
+ it('auto-registers the built-in navigate tool alongside manifest tools', async () => {
119
+ const client = await serveAndBoot();
120
+ const handle = installWebMCP(client);
121
+
122
+ await handle.sync();
123
+ expect(polyfillOf().listTools().map((tool) => tool.name)).toContain('navigate');
124
+ });
125
+
126
+ it('an app tool named navigate makes the built-in step aside', async () => {
127
+ manifestTools = [
128
+ { name: 'navigate', description: 'App-owned nav', guard: 'auto', input: { type: 'object', properties: { total: { type: 'number' } } } },
129
+ ];
130
+ const client = await serveAndBoot();
131
+ const handle = installWebMCP(client);
132
+
133
+ await handle.sync();
134
+ const navigate = polyfillOf().listTools().find((tool) => tool.name === 'navigate');
135
+
136
+ expect(navigate?.description).toContain('App-owned nav');
137
+ });
138
+
139
+ it('registers route manifest tools plus live local tools, with sanitized names', async () => {
140
+ const client = await serveAndBoot();
141
+
142
+ await client.call('counter.inc'); // mounts the island → local manifest is live
143
+ const handle = installWebMCP(client);
144
+
145
+ await handle.sync();
146
+ const names = polyfillOf()
147
+ .listTools()
148
+ .map((tool) => tool.name);
149
+
150
+ expect(names).toContain('api_shop_pay');
151
+ expect(names).toContain('counter_inc');
152
+ expect(names).toContain('counter_reset');
153
+ handle.dispose();
154
+ });
155
+
156
+ it('executes UI tools through the bridge and annotates confirm guards', async () => {
157
+ const client = await serveAndBoot();
158
+
159
+ await client.call('counter.inc');
160
+ const handle = installWebMCP(client);
161
+
162
+ await handle.sync();
163
+ const result: any = await polyfillOf().callTool('counter_inc');
164
+
165
+ await client.settled();
166
+ expect(document.querySelector('output')!.textContent).toBe('n=7');
167
+ expect(result.content[0].type).toBe('text');
168
+
169
+ const reset = polyfillOf()
170
+ .listTools()
171
+ .find((tool) => tool.name === 'counter_reset')!;
172
+
173
+ expect(reset.description).toContain('human must approve');
174
+ handle.dispose();
175
+ });
176
+
177
+ it('executes api.* tools over HTTP with the agent origin header', async () => {
178
+ const client = await serveAndBoot();
179
+ const handle = installWebMCP(client);
180
+
181
+ await handle.sync();
182
+ const result: any = await polyfillOf().callTool('api_shop_pay', { total: 25 });
183
+
184
+ expect(JSON.parse(result.content[0].text)).toEqual({ ok: true, result: 'paid' });
185
+ const apiCall = fetchMock.mock.calls.find(([url]) => String(url).startsWith('/_janux/api/'));
186
+
187
+ expect(String(apiCall![0])).toBe('/_janux/api/shop.pay');
188
+ expect((apiCall![1]!.headers as any)['x-janux-origin']).toBe('agent');
189
+ handle.dispose();
190
+ });
191
+
192
+ it('re-syncs on SPA navigation without duplicating registrations', async () => {
193
+ const client = await serveAndBoot();
194
+ const handle = installWebMCP(client);
195
+
196
+ await handle.sync();
197
+ manifestTools = [{ ...ROUTE_MANIFEST.tools[0]!, name: 'api.shop.refund' }];
198
+ document.dispatchEvent(
199
+ new CustomEvent('janux:navigate', { detail: { phase: 'after', from: '/', to: '/next' } }),
200
+ );
201
+ await handle.sync();
202
+ const names = polyfillOf()
203
+ .listTools()
204
+ .map((tool) => tool.name);
205
+
206
+ expect(names).toContain('api_shop_refund');
207
+ expect(names).not.toContain('api_shop_pay');
208
+ handle.dispose();
209
+ });
210
+
211
+ it('boot installs WebMCP by default (0 config)', async () => {
212
+ const { html } = await renderToString(jsx(counter as any, {}), {
213
+ initialState: { 'ui://counter#default': { n: 1 } },
214
+ });
215
+
216
+ document.body.innerHTML = html;
217
+ boot({ defs: [counter] });
218
+ await new Promise((resolve) => setTimeout(resolve, 0));
219
+ expect(polyfillOf()).toBeDefined();
220
+ expect(polyfillOf().polyfilled).toBe(true);
221
+ });
222
+ });