Skip to content

Commit 41de51a

Browse files
committed
test(js/client): cover expose() + ZhubExposure against a real ws server
8 node:test cases (47 → 55 pass) driving a real ws.WebSocketServer that impersonates the hub's /ws/expose endpoint — no mocks of the WS layer or of ZhubExposure itself. Covers: - register-exposure envelope shape (manifest + capabilities + null device_key when omitted; allow_publishers omitted when not supplied) - exposureId + deviceKey populated from 'exposure-registered' - invoke-request → capability handler → invoke-result happy path, handler-throws path, and unknown-capability path - allowPublishers threading (omitted vs whitelist vs [] kill switch) - deviceKey reuse so the hub can restore the same exposureId across restarts - runForever()/stop() lifecycle on ZhubExposure (blocks, resolves concurrent waiters, late-call fast path) Mutation-verified: dropping the handler args (line 879 'handler(args)' → 'handler({})') fails the dispatch test ('not ok 3 - dispatches invoke-request to the matching capability and replies with invoke-result'). Pre-port mutation surfaces at tsc — 'expose' / 'ZhubExposure' / 'ExposeOptions' are absent from '../src/client.js'.
1 parent 00ed88d commit 41de51a

2 files changed

Lines changed: 367 additions & 1 deletion

File tree

js/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
],
2727
"scripts": {
2828
"build": "tsc -p tsconfig.json",
29-
"test": "tsc -p tsconfig.test.json && node --test dist-test/test/manifest.test.js dist-test/test/manifest_mcp.test.js dist-test/test/protocol.test.js dist-test/test/client.test.js dist-test/test/client_url.test.js dist-test/test/client_lifecycle.test.js dist-test/test/client_stream.test.js dist-test/test/client_handle_chat.test.js"
29+
"test": "tsc -p tsconfig.test.json && node --test dist-test/test/manifest.test.js dist-test/test/manifest_mcp.test.js dist-test/test/protocol.test.js dist-test/test/client.test.js dist-test/test/client_url.test.js dist-test/test/client_lifecycle.test.js dist-test/test/client_stream.test.js dist-test/test/client_handle_chat.test.js dist-test/test/expose.test.js"
3030
},
3131
"dependencies": {
3232
"ws": "^8.18.0"

js/test/expose.test.ts

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
/**
2+
* expose() / ZhubExposure parity with Python (zhub.client.expose).
3+
*
4+
* Pre-port mutation: every test fails at tsc — `expose` / `ZhubExposure` /
5+
* `ExposeOptions` don't exist on `../src/client.js`, so a regression that
6+
* deletes the port is caught at compile time, not just at runtime.
7+
*
8+
* Tests stand up a real `ws.WebSocketServer` impersonating the hub's
9+
* `/ws/expose` endpoint and drive the exposure through the wire envelopes
10+
* the Python hub actually sends (`registered`/`invoke-request`/`error`).
11+
* No mocks of the WS layer or of ZhubExposure itself.
12+
*/
13+
import { describe, it } from 'node:test';
14+
import assert from 'node:assert/strict';
15+
import { WebSocketServer } from 'ws';
16+
import type { AddressInfo } from 'node:net';
17+
import { expose, ZhubExposure } from '../src/client.js';
18+
19+
function hubUrl(port: number): string {
20+
return `http://127.0.0.1:${port}`;
21+
}
22+
23+
/** Send the standard exposure-registered envelope back, mirroring the hub. */
24+
function sendRegistered(
25+
ws: import('ws').WebSocket,
26+
requestId: string,
27+
exposureId = 'ex_test',
28+
deviceKey = 'dx_test',
29+
name = 'cam',
30+
): void {
31+
ws.send(JSON.stringify({
32+
type: 'exposure-registered',
33+
request_id: requestId,
34+
payload: { exposure_id: exposureId, device_key: deviceKey, name },
35+
}));
36+
}
37+
38+
describe('expose()', () => {
39+
it('sends register-exposure with the manifest + capability list', async () => {
40+
const wss = new WebSocketServer({ port: 0 });
41+
await new Promise<void>((r) => wss.on('listening', r));
42+
const port = (wss.address() as AddressInfo).port;
43+
44+
let registerEnv: { type: string; payload: Record<string, unknown> } | null = null;
45+
const captured = new Promise<void>((resolve) => {
46+
wss.on('connection', (ws) => {
47+
ws.once('message', (raw) => {
48+
registerEnv = JSON.parse(raw.toString());
49+
sendRegistered(ws, registerEnv!.payload.name === 'cam' ? 'req' : 'req');
50+
resolve();
51+
});
52+
});
53+
});
54+
55+
const exp = expose({
56+
name: 'cam',
57+
capabilities: {
58+
snap: [{ type: 'object', properties: {} }, () => ({ ok: true })],
59+
zoom: [{ type: 'object', properties: { level: { type: 'integer' } } }, () => ({})],
60+
},
61+
hubUrl: hubUrl(port),
62+
description: 'security cam',
63+
operator: 'zawwarsami',
64+
});
65+
66+
await captured;
67+
await exp.stop();
68+
wss.close();
69+
70+
assert.equal(registerEnv!.type, 'register-exposure');
71+
assert.equal(registerEnv!.payload.name, 'cam');
72+
assert.equal(registerEnv!.payload.device_key, null);
73+
const manifest = registerEnv!.payload.manifest as Record<string, unknown>;
74+
assert.equal(manifest.name, 'cam');
75+
assert.equal(manifest.description, 'security cam');
76+
assert.equal(manifest.operator, 'zawwarsami');
77+
// Capabilities listed; schemas threaded; chat-only schema NOT injected
78+
// (expose mode publishes user-supplied capabilities only).
79+
const caps = manifest.capabilities as Array<Record<string, unknown>>;
80+
assert.equal(caps.length, 2);
81+
assert.deepEqual(caps.map((c) => c.name).sort(), ['snap', 'zoom']);
82+
const zoom = caps.find((c) => c.name === 'zoom')!;
83+
assert.deepEqual(zoom.schema, { type: 'object', properties: { level: { type: 'integer' } } });
84+
// allow_publishers must be absent when option omitted (Python parity:
85+
// None = backwards-compatible "any publisher").
86+
assert.equal('allow_publishers' in manifest, false);
87+
});
88+
89+
it('populates exposureId + deviceKey on exposure-registered', async () => {
90+
const wss = new WebSocketServer({ port: 0 });
91+
await new Promise<void>((r) => wss.on('listening', r));
92+
const port = (wss.address() as AddressInfo).port;
93+
94+
wss.on('connection', (ws) => {
95+
ws.once('message', (raw) => {
96+
const env = JSON.parse(raw.toString());
97+
sendRegistered(ws, env.request_id, 'ex_abcdef', 'dx_secret123');
98+
});
99+
});
100+
101+
const exp = expose({
102+
name: 'cam',
103+
capabilities: { ping: [{}, () => ({})] },
104+
hubUrl: hubUrl(port),
105+
});
106+
107+
// Wait for registration round-trip; deviceKey is only set after the WS
108+
// message handler fires.
109+
for (let i = 0; i < 50 && !exp.exposureId; i++) {
110+
await new Promise((r) => setTimeout(r, 20));
111+
}
112+
await exp.stop();
113+
wss.close();
114+
115+
assert.equal(exp.exposureId, 'ex_abcdef');
116+
assert.equal(exp.deviceKey, 'dx_secret123');
117+
});
118+
119+
it('dispatches invoke-request to the matching capability and replies with invoke-result', async () => {
120+
const wss = new WebSocketServer({ port: 0 });
121+
await new Promise<void>((r) => wss.on('listening', r));
122+
const port = (wss.address() as AddressInfo).port;
123+
124+
const replies: Array<{ type: string; payload: Record<string, unknown> }> = [];
125+
const got = new Promise<void>((resolve) => {
126+
wss.on('connection', (ws) => {
127+
ws.on('message', (raw) => {
128+
const env = JSON.parse(raw.toString());
129+
if (env.type === 'register-exposure') {
130+
sendRegistered(ws, env.request_id);
131+
ws.send(JSON.stringify({
132+
type: 'invoke-request',
133+
request_id: 'inv-1',
134+
payload: { capability: 'snap', args: { quality: 'hi' } },
135+
}));
136+
return;
137+
}
138+
if (env.type === 'invoke-result') {
139+
replies.push(env);
140+
resolve();
141+
}
142+
});
143+
});
144+
});
145+
146+
let received: Record<string, unknown> | null = null;
147+
const exp = expose({
148+
name: 'cam',
149+
capabilities: {
150+
snap: [{}, (args) => {
151+
received = args;
152+
return { url: '/img/1.jpg' };
153+
}],
154+
},
155+
hubUrl: hubUrl(port),
156+
});
157+
158+
await got;
159+
await exp.stop();
160+
wss.close();
161+
162+
assert.deepEqual(received, { quality: 'hi' });
163+
assert.equal(replies.length, 1);
164+
assert.equal(replies[0].type, 'invoke-result');
165+
assert.equal(replies[0].payload.ok, true);
166+
assert.deepEqual(replies[0].payload.result, { url: '/img/1.jpg' });
167+
assert.equal(replies[0].payload.error, null);
168+
});
169+
170+
it('replies invoke-result {ok:false} when the handler throws', async () => {
171+
const wss = new WebSocketServer({ port: 0 });
172+
await new Promise<void>((r) => wss.on('listening', r));
173+
const port = (wss.address() as AddressInfo).port;
174+
175+
let reply: { type: string; payload: Record<string, unknown> } | null = null;
176+
const got = new Promise<void>((resolve) => {
177+
wss.on('connection', (ws) => {
178+
ws.on('message', (raw) => {
179+
const env = JSON.parse(raw.toString());
180+
if (env.type === 'register-exposure') {
181+
sendRegistered(ws, env.request_id);
182+
ws.send(JSON.stringify({
183+
type: 'invoke-request',
184+
request_id: 'inv-1',
185+
payload: { capability: 'snap', args: {} },
186+
}));
187+
return;
188+
}
189+
if (env.type === 'invoke-result') {
190+
reply = env;
191+
resolve();
192+
}
193+
});
194+
});
195+
});
196+
197+
const exp = expose({
198+
name: 'cam',
199+
capabilities: {
200+
snap: [{}, () => { throw new Error('camera offline'); }],
201+
},
202+
hubUrl: hubUrl(port),
203+
});
204+
await got;
205+
await exp.stop();
206+
wss.close();
207+
208+
assert.equal(reply!.payload.ok, false);
209+
assert.equal(reply!.payload.result, null);
210+
assert.equal(reply!.payload.error, 'camera offline');
211+
});
212+
213+
it("replies invoke-result {ok:false, error:'capability ... not exposed'} on unknown capability", async () => {
214+
const wss = new WebSocketServer({ port: 0 });
215+
await new Promise<void>((r) => wss.on('listening', r));
216+
const port = (wss.address() as AddressInfo).port;
217+
218+
let reply: { type: string; payload: Record<string, unknown> } | null = null;
219+
const got = new Promise<void>((resolve) => {
220+
wss.on('connection', (ws) => {
221+
ws.on('message', (raw) => {
222+
const env = JSON.parse(raw.toString());
223+
if (env.type === 'register-exposure') {
224+
sendRegistered(ws, env.request_id);
225+
ws.send(JSON.stringify({
226+
type: 'invoke-request',
227+
request_id: 'inv-1',
228+
payload: { capability: 'ghost', args: {} },
229+
}));
230+
return;
231+
}
232+
if (env.type === 'invoke-result') {
233+
reply = env;
234+
resolve();
235+
}
236+
});
237+
});
238+
});
239+
240+
const exp = expose({
241+
name: 'cam',
242+
capabilities: { snap: [{}, () => ({})] },
243+
hubUrl: hubUrl(port),
244+
});
245+
await got;
246+
await exp.stop();
247+
wss.close();
248+
249+
assert.equal(reply!.payload.ok, false);
250+
assert.equal(reply!.payload.error, "capability 'ghost' not exposed");
251+
});
252+
253+
it('threads allowPublishers as manifest.allow_publishers (whitelist + kill switch)', async () => {
254+
async function captureManifest(allowPublishers: string[] | undefined): Promise<Record<string, unknown>> {
255+
const wss = new WebSocketServer({ port: 0 });
256+
await new Promise<void>((r) => wss.on('listening', r));
257+
const port = (wss.address() as AddressInfo).port;
258+
let manifest: Record<string, unknown> = {};
259+
const got = new Promise<void>((resolve) => {
260+
wss.on('connection', (ws) => {
261+
ws.once('message', (raw) => {
262+
const env = JSON.parse(raw.toString());
263+
manifest = env.payload.manifest as Record<string, unknown>;
264+
sendRegistered(ws, env.request_id);
265+
resolve();
266+
});
267+
});
268+
});
269+
const exp = expose({
270+
name: 'cam',
271+
capabilities: { snap: [{}, () => ({})] },
272+
hubUrl: hubUrl(port),
273+
allowPublishers,
274+
});
275+
await got;
276+
await exp.stop();
277+
wss.close();
278+
return manifest;
279+
}
280+
281+
const omitted = await captureManifest(undefined);
282+
assert.equal('allow_publishers' in omitted, false, 'omitted → key absent');
283+
284+
const whitelist = await captureManifest(['alpha', 'beta']);
285+
assert.deepEqual(whitelist.allow_publishers, ['alpha', 'beta']);
286+
287+
const killswitch = await captureManifest([]);
288+
assert.deepEqual(killswitch.allow_publishers, [], 'empty list distinct from undefined');
289+
});
290+
291+
it('reuses deviceKey across reconnects so the same exposureId is restored', async () => {
292+
// Simulates the Python re-registration contract: caller passes back the
293+
// previous deviceKey; subsequent connects send that key in
294+
// register-exposure.
295+
const wss = new WebSocketServer({ port: 0 });
296+
await new Promise<void>((r) => wss.on('listening', r));
297+
const port = (wss.address() as AddressInfo).port;
298+
299+
const seen: Array<string | null> = [];
300+
wss.on('connection', (ws) => {
301+
ws.once('message', (raw) => {
302+
const env = JSON.parse(raw.toString());
303+
seen.push((env.payload.device_key as string | null) ?? null);
304+
sendRegistered(ws, env.request_id, 'ex_stable', 'dx_stable');
305+
});
306+
});
307+
308+
const exp = expose({
309+
name: 'cam',
310+
capabilities: { ping: [{}, () => ({})] },
311+
hubUrl: hubUrl(port),
312+
deviceKey: 'dx_stable',
313+
});
314+
for (let i = 0; i < 50 && !exp.deviceKey; i++) {
315+
await new Promise((r) => setTimeout(r, 20));
316+
}
317+
assert.equal(exp.deviceKey, 'dx_stable');
318+
assert.equal(exp.exposureId, 'ex_stable');
319+
assert.equal(seen[0], 'dx_stable', 'first register sends the passed-in deviceKey');
320+
321+
await exp.stop();
322+
wss.close();
323+
});
324+
});
325+
326+
describe('ZhubExposure.runForever()', () => {
327+
it('blocks until stop() is called and resolves concurrent waiters', async () => {
328+
// Direct ZhubExposure (no expose() reconnect loop) — same lifecycle
329+
// contract the publish/connect classes use.
330+
const exp = new ZhubExposure(
331+
{
332+
name: 'x',
333+
capabilities: {},
334+
hubUrl: 'http://127.0.0.1:1',
335+
},
336+
{
337+
schema_version: '0.1',
338+
name: 'x',
339+
description: '',
340+
operator: '',
341+
capabilities: [],
342+
auth: { type: 'bearer' },
343+
rate_limit: '60/min',
344+
public: true,
345+
contact: '',
346+
extensions: {},
347+
},
348+
);
349+
let resolvedA = false;
350+
let resolvedB = false;
351+
const a = exp.runForever().then(() => { resolvedA = true; });
352+
const b = exp.runForever().then(() => { resolvedB = true; });
353+
await new Promise((r) => setTimeout(r, 50));
354+
assert.equal(resolvedA, false);
355+
assert.equal(resolvedB, false);
356+
await exp.stop();
357+
await Promise.all([a, b]);
358+
assert.equal(resolvedA, true);
359+
assert.equal(resolvedB, true);
360+
361+
// Late call → already-resolved fast path.
362+
const t0 = Date.now();
363+
await exp.runForever();
364+
assert(Date.now() - t0 < 100);
365+
});
366+
});

0 commit comments

Comments
 (0)