@moxxy/plugin-channel-web 0.28.1 → 0.29.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/channel.d.ts.map +1 -1
- package/dist/channel.js +34 -15
- package/dist/channel.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/public/app.js +1 -1
- package/package.json +5 -5
- package/src/channel.test.ts +8 -4
- package/src/channel.ts +33 -20
- package/src/discovery.test.ts +6 -1
- package/src/frontend/chat.tsx +4 -4
- package/src/frontend/render-diff.test.ts +4 -1
- package/src/frontend/render.tsx +6 -2
- package/src/frontend/view-store.test.ts +3 -1
- package/src/frontend/view-store.ts +2 -1
- package/src/index.ts +6 -2
- package/src/projector.test.ts +24 -8
- package/src/tunnel-tools.test.ts +4 -1
package/src/channel.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
2
|
import { WebSocket } from 'ws';
|
|
3
3
|
import type { ClientSession, MoxxyEvent } from '@moxxy/sdk';
|
|
4
|
+
import { assertDefined } from '@moxxy/sdk';
|
|
4
5
|
import {
|
|
5
6
|
freeTcpPortIfMoxxy,
|
|
6
7
|
WebChannel,
|
|
@@ -188,7 +189,8 @@ describe('WebChannel', () => {
|
|
|
188
189
|
});
|
|
189
190
|
handle = await channel.start({ session: fakeSession().session });
|
|
190
191
|
expect(published).not.toBeNull();
|
|
191
|
-
|
|
192
|
+
assertDefined(published, 'a surface was published');
|
|
193
|
+
expect(published.url).toBe('https://abc.trycloudflare.com/?t=tkn');
|
|
192
194
|
});
|
|
193
195
|
|
|
194
196
|
it('co-attached to a real Session publishes a localhost URL by default (the TUI case)', async () => {
|
|
@@ -206,8 +208,9 @@ describe('WebChannel', () => {
|
|
|
206
208
|
});
|
|
207
209
|
handle = await channel.start({ session: session as never });
|
|
208
210
|
expect(published).not.toBeNull();
|
|
211
|
+
assertDefined(published, 'a surface was published');
|
|
209
212
|
// A real, tokenized URL present_view can hand back — never null/rendered:false.
|
|
210
|
-
expect(published
|
|
213
|
+
expect(published.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/\?t=tkn$/);
|
|
211
214
|
});
|
|
212
215
|
|
|
213
216
|
it('falls back to the local URL when the tunnel provider fails', async () => {
|
|
@@ -227,8 +230,9 @@ describe('WebChannel', () => {
|
|
|
227
230
|
});
|
|
228
231
|
handle = await channel.start({ session: fakeSession().session });
|
|
229
232
|
expect(published).not.toBeNull();
|
|
230
|
-
|
|
231
|
-
expect(published
|
|
233
|
+
assertDefined(published, 'a surface was published');
|
|
234
|
+
expect(published.url).toContain('http://127.0.0.1:');
|
|
235
|
+
expect(published.url).toContain('?t=tkn');
|
|
232
236
|
});
|
|
233
237
|
|
|
234
238
|
it('opens a WS handshake under the relay base path (proxy /web)', async () => {
|
package/src/channel.ts
CHANGED
|
@@ -122,9 +122,10 @@ export async function freeTcpPortIfMoxxy(
|
|
|
122
122
|
);
|
|
123
123
|
const foreign = holders.filter((h) => !looksLikeMoxxy(h.command));
|
|
124
124
|
if (foreign.length > 0) {
|
|
125
|
-
logger?.warn
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
if (logger?.warn)
|
|
126
|
+
logger.warn(`port ${port} is held by non-moxxy process(es); not killing them`, {
|
|
127
|
+
holders: foreign.map((h) => `${h.pid}: ${h.command || '<unknown command>'}`),
|
|
128
|
+
});
|
|
128
129
|
return false;
|
|
129
130
|
}
|
|
130
131
|
// Re-verify identity immediately before each signal. Between the `ps`
|
|
@@ -139,7 +140,7 @@ export async function freeTcpPortIfMoxxy(
|
|
|
139
140
|
try {
|
|
140
141
|
// Log the exact command we judged moxxy-owned before signalling, so a
|
|
141
142
|
// mis-fire on a substring-collision process is auditable after the fact.
|
|
142
|
-
logger?.warn
|
|
143
|
+
if (logger?.warn) logger.warn('freeing port: SIGTERM to apparent stale moxxy process', { pid, command });
|
|
143
144
|
deps.kill(pid, 'SIGTERM');
|
|
144
145
|
attempted = true;
|
|
145
146
|
} catch {
|
|
@@ -158,7 +159,7 @@ export async function freeTcpPortIfMoxxy(
|
|
|
158
159
|
const command = await deps.pidCommand(pid);
|
|
159
160
|
if (!looksLikeMoxxy(command)) continue;
|
|
160
161
|
try {
|
|
161
|
-
logger?.warn
|
|
162
|
+
if (logger?.warn) logger.warn('freeing port: SIGKILL to apparent stale moxxy process', { pid, command });
|
|
162
163
|
deps.kill(pid, 'SIGKILL');
|
|
163
164
|
} catch {
|
|
164
165
|
/* dead */
|
|
@@ -328,7 +329,10 @@ export class WebChannel implements Channel<WebStartOpts> {
|
|
|
328
329
|
wss.on('connection', (ws) => this.onConnection(ws));
|
|
329
330
|
// Never leave an EventEmitter 'error' unhandled — it would throw at the
|
|
330
331
|
// process level. Forwarded server errors after bind are log-and-survive.
|
|
331
|
-
wss.on('error', (err) =>
|
|
332
|
+
wss.on('error', (err) => {
|
|
333
|
+
const logger = this.logger;
|
|
334
|
+
if (logger?.warn) logger.warn('web socket server error', { err: String(err) });
|
|
335
|
+
});
|
|
332
336
|
|
|
333
337
|
await this.openTunnel();
|
|
334
338
|
this.publishSurface?.({ url: this.shareUrl, nextViewId: () => `v_srv_${++this.viewSeq}` });
|
|
@@ -358,7 +362,8 @@ export class WebChannel implements Channel<WebStartOpts> {
|
|
|
358
362
|
server.off('error', onError);
|
|
359
363
|
const addr = server.address();
|
|
360
364
|
if (addr && typeof addr === 'object') this.port = (addr as AddressInfo).port;
|
|
361
|
-
|
|
365
|
+
const logger = this.logger;
|
|
366
|
+
if (logger?.info) logger.info('web channel listening', { url: this.url });
|
|
362
367
|
resolve();
|
|
363
368
|
};
|
|
364
369
|
server.once('error', onError);
|
|
@@ -376,9 +381,11 @@ export class WebChannel implements Channel<WebStartOpts> {
|
|
|
376
381
|
const requested = this.port;
|
|
377
382
|
const freed = await freeTcpPortIfMoxxy(requested, this.logger).catch(() => false);
|
|
378
383
|
if (freed) {
|
|
379
|
-
this.logger
|
|
380
|
-
|
|
381
|
-
|
|
384
|
+
const logger = this.logger;
|
|
385
|
+
if (logger?.warn)
|
|
386
|
+
logger.warn(
|
|
387
|
+
`web channel port ${requested} was held by a stale moxxy process; freed it, retrying`,
|
|
388
|
+
);
|
|
382
389
|
try {
|
|
383
390
|
await tryListen();
|
|
384
391
|
return;
|
|
@@ -392,10 +399,12 @@ export class WebChannel implements Channel<WebStartOpts> {
|
|
|
392
399
|
// this.url / shareUrl / the tunnel all carry it automatically.
|
|
393
400
|
this.port = 0;
|
|
394
401
|
await tryListen();
|
|
395
|
-
this.logger
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
402
|
+
const logger = this.logger;
|
|
403
|
+
if (logger?.warn)
|
|
404
|
+
logger.warn(
|
|
405
|
+
`web channel port ${requested} was in use by another process; bound ephemeral port ${this.port} instead`,
|
|
406
|
+
{ requestedPort: requested, boundPort: this.port, url: this.url },
|
|
407
|
+
);
|
|
399
408
|
}
|
|
400
409
|
|
|
401
410
|
/**
|
|
@@ -422,9 +431,11 @@ export class WebChannel implements Channel<WebStartOpts> {
|
|
|
422
431
|
this.tunnel = await provider.open({ port: this.port, host: this.host, label: 'web' });
|
|
423
432
|
this.tunnelBase = this.tunnel.url;
|
|
424
433
|
this.basePath = basePathFromUrl(this.tunnel.url);
|
|
425
|
-
|
|
434
|
+
const logger = this.logger;
|
|
435
|
+
if (logger?.info) logger.info('web surface tunnel open', { provider: provider.name, url: this.shareUrl });
|
|
426
436
|
} catch (err) {
|
|
427
|
-
|
|
437
|
+
const logger = this.logger;
|
|
438
|
+
if (logger?.warn) logger.warn('web surface tunnel failed; using local URL', { provider: provider.name, err: String(err) });
|
|
428
439
|
}
|
|
429
440
|
}
|
|
430
441
|
|
|
@@ -622,10 +633,12 @@ export class WebChannel implements Channel<WebStartOpts> {
|
|
|
622
633
|
const now = Date.now();
|
|
623
634
|
if (now - this.lastDropWarnAt < DROP_WARN_INTERVAL_MS) return;
|
|
624
635
|
this.lastDropWarnAt = now;
|
|
625
|
-
this.logger
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
636
|
+
const logger = this.logger;
|
|
637
|
+
if (logger?.warn)
|
|
638
|
+
logger.warn('web channel dropped invalid client frame(s)', {
|
|
639
|
+
reason,
|
|
640
|
+
droppedTotal: this.droppedFrames,
|
|
641
|
+
});
|
|
629
642
|
}
|
|
630
643
|
|
|
631
644
|
private async drive(prompt: string): Promise<void> {
|
package/src/discovery.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import type { ServiceRegistry } from '@moxxy/sdk';
|
|
3
|
+
import { assertDefined } from '@moxxy/sdk';
|
|
3
4
|
import { webChannelPlugin } from './index.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -32,7 +33,11 @@ describe('webChannelPlugin (discovery-loadable)', () => {
|
|
|
32
33
|
}
|
|
33
34
|
});
|
|
34
35
|
const services = { get, register: () => {}, require: () => undefined, has: () => true } as unknown as ServiceRegistry;
|
|
35
|
-
webChannelPlugin.hooks
|
|
36
|
+
const hooks = webChannelPlugin.hooks;
|
|
37
|
+
assertDefined(hooks, 'plugin defines hooks');
|
|
38
|
+
const onInit = hooks.onInit;
|
|
39
|
+
assertDefined(onInit, 'hooks define onInit');
|
|
40
|
+
onInit({ services } as never);
|
|
36
41
|
expect(get).toHaveBeenCalledWith('tunnelProviders');
|
|
37
42
|
expect(get).toHaveBeenCalledWith('viewSurface');
|
|
38
43
|
expect(get).toHaveBeenCalledWith('webControls');
|
package/src/frontend/chat.tsx
CHANGED
|
@@ -51,7 +51,7 @@ export function ChatPanel(props: {
|
|
|
51
51
|
useEffect(() => {
|
|
52
52
|
const opener = typeof document !== 'undefined' ? (document.activeElement as HTMLElement | null) : null;
|
|
53
53
|
inputRef.current?.focus();
|
|
54
|
-
return () => opener?.focus
|
|
54
|
+
return () => opener?.focus();
|
|
55
55
|
}, []);
|
|
56
56
|
|
|
57
57
|
const onKeyDown = useCallback(
|
|
@@ -64,9 +64,9 @@ export function ChatPanel(props: {
|
|
|
64
64
|
if (e.key !== 'Tab') return;
|
|
65
65
|
// Trap Tab within the panel so focus never escapes to the obscured view.
|
|
66
66
|
const focusables = focusableWithin(panelRef.current);
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
67
|
+
const first = focusables[0];
|
|
68
|
+
const last = focusables[focusables.length - 1];
|
|
69
|
+
if (!first || !last) return;
|
|
70
70
|
const active = document.activeElement;
|
|
71
71
|
if (e.shiftKey && active === first) {
|
|
72
72
|
e.preventDefault();
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
import { createElement } from 'react';
|
|
3
3
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
4
4
|
import type { FileDiffDisplay } from '@moxxy/sdk';
|
|
5
|
+
import { assertDefined } from '@moxxy/sdk';
|
|
5
6
|
import { FileDiffView } from './render-diff.js';
|
|
6
7
|
|
|
7
8
|
const render = (display: FileDiffDisplay): string => renderToStaticMarkup(createElement(FileDiffView, { display }));
|
|
@@ -58,10 +59,12 @@ describe('FileDiffView', () => {
|
|
|
58
59
|
});
|
|
59
60
|
|
|
60
61
|
it('inserts a ⋯ gap row between non-contiguous hunks', () => {
|
|
62
|
+
const hunk = baseDisplay.hunks[0];
|
|
63
|
+
assertDefined(hunk, 'baseDisplay has a hunk');
|
|
61
64
|
const twoHunks: FileDiffDisplay = {
|
|
62
65
|
...baseDisplay,
|
|
63
66
|
hunks: [
|
|
64
|
-
|
|
67
|
+
hunk,
|
|
65
68
|
{ oldStart: 40, oldLines: 1, newStart: 41, newLines: 1, lines: [{ kind: 'context', text: 'far away', oldNo: 40, newNo: 41 }] },
|
|
66
69
|
],
|
|
67
70
|
};
|
package/src/frontend/render.tsx
CHANGED
|
@@ -110,6 +110,7 @@ export function renderNode(node: ViewNode, h: RenderHandlers, key?: number): Rea
|
|
|
110
110
|
case 'link': {
|
|
111
111
|
// A `to` link navigates client-side; otherwise it is an external anchor.
|
|
112
112
|
if (node.nav) {
|
|
113
|
+
const nav = node.nav;
|
|
113
114
|
return (
|
|
114
115
|
<a
|
|
115
116
|
className="v-link"
|
|
@@ -117,7 +118,7 @@ export function renderNode(node: ViewNode, h: RenderHandlers, key?: number): Rea
|
|
|
117
118
|
key={key}
|
|
118
119
|
onClick={(e) => {
|
|
119
120
|
e.preventDefault();
|
|
120
|
-
h.navigate(
|
|
121
|
+
h.navigate(nav);
|
|
121
122
|
}}
|
|
122
123
|
>
|
|
123
124
|
{kids}
|
|
@@ -223,7 +224,10 @@ function gatherFormValues(form: HTMLFormElement | null): Record<string, string>
|
|
|
223
224
|
function pick(values: Record<string, string>, fields: ReadonlyArray<string>): Record<string, string> {
|
|
224
225
|
if (fields.length === 0) return values;
|
|
225
226
|
const out: Record<string, string> = {};
|
|
226
|
-
for (const f of fields)
|
|
227
|
+
for (const f of fields) {
|
|
228
|
+
const v = values[f];
|
|
229
|
+
if (v !== undefined) out[f] = v;
|
|
230
|
+
}
|
|
227
231
|
return out;
|
|
228
232
|
}
|
|
229
233
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import type { ViewDoc } from '@moxxy/sdk';
|
|
3
|
+
import { assertDefined } from '@moxxy/sdk';
|
|
3
4
|
import { applyView, canGoBack, currentEntry, goBack, initialNav, navigateTo } from './view-store.js';
|
|
4
5
|
|
|
5
6
|
const doc = (tag = 'view'): ViewDoc => ({ root: { kind: 'element', tag, props: {}, children: [] } });
|
|
@@ -48,7 +49,8 @@ describe('view-store', () => {
|
|
|
48
49
|
s = applyView(s, frame('v2', 'results'));
|
|
49
50
|
const back = navigateTo(s, 'search');
|
|
50
51
|
expect(back).not.toBeNull();
|
|
51
|
-
|
|
52
|
+
assertDefined(back, 'navigateTo returned a cached view');
|
|
53
|
+
expect(currentEntry(back)?.key).toBe('search');
|
|
52
54
|
expect(navigateTo(s, 'detail')).toBeNull(); // not cached → caller asks the agent
|
|
53
55
|
});
|
|
54
56
|
|
|
@@ -78,7 +78,8 @@ function prune(state: NavState): NavState {
|
|
|
78
78
|
const order = [...state.order];
|
|
79
79
|
// Walk oldest→newest, dropping evictable (non-history) keys until within cap.
|
|
80
80
|
for (let i = 0; i < order.length && order.length > MAX_CACHED_VIEWS; ) {
|
|
81
|
-
const key = order[i]
|
|
81
|
+
const key = order[i];
|
|
82
|
+
if (key === undefined) break;
|
|
82
83
|
if (live.has(key)) {
|
|
83
84
|
i += 1; // pinned by history — skip, keep it
|
|
84
85
|
continue;
|
package/src/index.ts
CHANGED
|
@@ -97,7 +97,8 @@ export function buildWebChannelPlugin(opts: BuildWebChannelOptions = {}): Plugin
|
|
|
97
97
|
}
|
|
98
98
|
tunnels.setActive(name);
|
|
99
99
|
await writeTunnelSetting(name, opts.settingsFile);
|
|
100
|
-
const
|
|
100
|
+
const controls = opts.getControls?.();
|
|
101
|
+
const url = (await controls?.retunnel()) ?? null;
|
|
101
102
|
return { ok: true, active: name, ...(url ? { url } : { note: 'applies when the web surface (re)starts' }) };
|
|
102
103
|
},
|
|
103
104
|
}),
|
|
@@ -166,7 +167,10 @@ export const webChannelPlugin: Plugin = (() => {
|
|
|
166
167
|
|
|
167
168
|
const tunnels: TunnelControls = {
|
|
168
169
|
list: () => tp?.list().map((p) => p.name) ?? [],
|
|
169
|
-
active: () =>
|
|
170
|
+
active: () => {
|
|
171
|
+
const activeTunnel = tp?.getActive();
|
|
172
|
+
return activeTunnel?.name ?? null;
|
|
173
|
+
},
|
|
170
174
|
setActive: (n) => {
|
|
171
175
|
tp?.setActive(n);
|
|
172
176
|
},
|
package/src/projector.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import type { MoxxyEvent } from '@moxxy/sdk';
|
|
3
|
+
import { assertDefined } from '@moxxy/sdk';
|
|
3
4
|
import { EventProjector } from './projector.js';
|
|
4
5
|
|
|
5
6
|
// Minimal event factory — only the fields the projector reads.
|
|
@@ -15,7 +16,8 @@ describe('EventProjector', () => {
|
|
|
15
16
|
expect(p.project(ev({ type: 'tool_call_requested', callId: 'c1', name: 'present_view', input: { fallbackText: 'fb' } }))).toEqual([]);
|
|
16
17
|
const frames = p.project(ev({ type: 'tool_result', callId: 'c1', ok: true, output: { ast: sampleDoc } }));
|
|
17
18
|
expect(frames).toHaveLength(1);
|
|
18
|
-
const f = frames[0]
|
|
19
|
+
const f = frames[0];
|
|
20
|
+
assertDefined(f, 'projector emitted a view frame');
|
|
19
21
|
expect(f.kind).toBe('view');
|
|
20
22
|
if (f.kind !== 'view') return;
|
|
21
23
|
expect(f.doc).toEqual(sampleDoc);
|
|
@@ -26,9 +28,13 @@ describe('EventProjector', () => {
|
|
|
26
28
|
it('sets `replaces` to the prior view id on the second view', () => {
|
|
27
29
|
const p = new EventProjector();
|
|
28
30
|
p.project(ev({ type: 'tool_call_requested', callId: 'c1', name: 'present_view', input: {} }));
|
|
29
|
-
const
|
|
31
|
+
const firstFrames = p.project(ev({ type: 'tool_result', callId: 'c1', ok: true, output: { ast: sampleDoc } }));
|
|
32
|
+
const first = firstFrames[0];
|
|
33
|
+
assertDefined(first, 'projector emitted a view frame');
|
|
30
34
|
p.project(ev({ type: 'tool_call_requested', callId: 'c2', name: 'present_view', input: {} }));
|
|
31
|
-
const
|
|
35
|
+
const secondFrames = p.project(ev({ type: 'tool_result', callId: 'c2', ok: true, output: { ast: sampleDoc } }));
|
|
36
|
+
const second = secondFrames[0];
|
|
37
|
+
assertDefined(second, 'projector emitted a view frame');
|
|
32
38
|
if (first.kind !== 'view' || second.kind !== 'view') throw new Error('expected views');
|
|
33
39
|
expect(second.replaces).toBe(first.viewId);
|
|
34
40
|
expect(second.viewId).not.toBe(first.viewId);
|
|
@@ -38,14 +44,18 @@ describe('EventProjector', () => {
|
|
|
38
44
|
const p = new EventProjector();
|
|
39
45
|
p.project(ev({ type: 'tool_call_requested', callId: 'c1', name: 'present_view', input: {} }));
|
|
40
46
|
const namedDoc = { root: { kind: 'element', tag: 'view', props: { name: 'search' }, children: [] } };
|
|
41
|
-
const
|
|
47
|
+
const namedFrames = p.project(ev({ type: 'tool_result', callId: 'c1', ok: true, output: { ast: namedDoc } }));
|
|
48
|
+
const f = namedFrames[0];
|
|
49
|
+
assertDefined(f, 'projector emitted a view frame');
|
|
42
50
|
expect(f.kind === 'view' && f.name).toBe('search');
|
|
43
51
|
});
|
|
44
52
|
|
|
45
53
|
it('omits name when the view has none', () => {
|
|
46
54
|
const p = new EventProjector();
|
|
47
55
|
p.project(ev({ type: 'tool_call_requested', callId: 'c1', name: 'present_view', input: {} }));
|
|
48
|
-
const
|
|
56
|
+
const frames = p.project(ev({ type: 'tool_result', callId: 'c1', ok: true, output: { ast: sampleDoc } }));
|
|
57
|
+
const f = frames[0];
|
|
58
|
+
assertDefined(f, 'projector emitted a view frame');
|
|
49
59
|
expect(f.kind === 'view' && f.name).toBeUndefined();
|
|
50
60
|
});
|
|
51
61
|
|
|
@@ -142,9 +152,13 @@ describe('EventProjector', () => {
|
|
|
142
152
|
it('handles two present_view calls in one turn, each replacing the last', () => {
|
|
143
153
|
const p = new EventProjector();
|
|
144
154
|
p.project(ev({ type: 'tool_call_requested', callId: 'a', name: 'present_view', input: {} }));
|
|
145
|
-
const
|
|
155
|
+
const firstFrames = p.project(ev({ type: 'tool_result', callId: 'a', ok: true, output: { ast: sampleDoc } }));
|
|
156
|
+
const first = firstFrames[0];
|
|
157
|
+
assertDefined(first, 'projector emitted a view frame');
|
|
146
158
|
p.project(ev({ type: 'tool_call_requested', callId: 'b', name: 'present_view', input: {} }));
|
|
147
|
-
const
|
|
159
|
+
const secondFrames = p.project(ev({ type: 'tool_result', callId: 'b', ok: true, output: { ast: sampleDoc } }));
|
|
160
|
+
const second = secondFrames[0];
|
|
161
|
+
assertDefined(second, 'projector emitted a view frame');
|
|
148
162
|
if (first.kind !== 'view' || second.kind !== 'view') throw new Error('views');
|
|
149
163
|
expect(second.replaces).toBe(first.viewId);
|
|
150
164
|
});
|
|
@@ -167,6 +181,8 @@ describe('EventProjector', () => {
|
|
|
167
181
|
p.project(ev({ type: 'tool_call_requested', callId: 'live', name: 'present_view', input: {} }));
|
|
168
182
|
const frames = p.project(ev({ type: 'tool_result', callId: 'live', ok: true, output: { ast: sampleDoc } }));
|
|
169
183
|
expect(frames).toHaveLength(1);
|
|
170
|
-
|
|
184
|
+
const frame = frames[0];
|
|
185
|
+
assertDefined(frame, 'projector emitted a view frame');
|
|
186
|
+
expect(frame.kind).toBe('view');
|
|
171
187
|
});
|
|
172
188
|
});
|
package/src/tunnel-tools.test.ts
CHANGED
|
@@ -94,7 +94,10 @@ describe('web_set_tunnel', () => {
|
|
|
94
94
|
});
|
|
95
95
|
|
|
96
96
|
describe('onInit applies the persisted / default tunnel', () => {
|
|
97
|
-
const fireInit = (hooks: LifecycleHooks | undefined) =>
|
|
97
|
+
const fireInit = (hooks: LifecycleHooks | undefined) => {
|
|
98
|
+
const onInit = hooks?.onInit as (() => void) | undefined;
|
|
99
|
+
onInit?.();
|
|
100
|
+
};
|
|
98
101
|
|
|
99
102
|
it('applies a persisted setting on boot', async () => {
|
|
100
103
|
await writeTunnelSetting('proxy', file);
|