Skip to content

Commit 59f8aed

Browse files
committed
test(js/client): cover non-Error throws across invoke + chat paths
Four tests against a real ws.WebSocketServer (no WS-layer mocks): an exposure capability throwing a string, an exposure throwing a plain object, a connection capability throwing a string, and a publication chat-handler throwing a string. Each asserts the readable wire payload the publisher / connection / exposure sends back. Mutation-verified: reverting errorMessage(e) to (e as Error).message fails exactly these 4 of the 59 JS tests — the string-throw cases land on `error: undefined` (stripped from JSON, payload.error is then literally absent) and the plain-object case asserts the readable String() coercion is preserved.
1 parent d66a926 commit 59f8aed

2 files changed

Lines changed: 224 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 dist-test/test/expose.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 dist-test/test/client_nonerror_throw.test.js"
3030
},
3131
"dependencies": {
3232
"ws": "^8.18.0"
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
/**
2+
* Capability / chat handlers that throw non-Error values must surface a
3+
* readable diagnostic on the wire — mirror of Python's `str(e)` everywhere
4+
* client.py catches a handler exception.
5+
*
6+
* Pre-fix, both invoke-request branches (`ZhubConnection`, `ZhubExposure`)
7+
* and `handleChat()` read `(e as Error).message`. JS lets a handler throw
8+
* anything — a string (`throw "bad input"`), a plain object, a number, even
9+
* undefined — and on those values `.message` is `undefined`. JSON.stringify
10+
* then drops `error: undefined`, so:
11+
* - invoke caller saw `{ok:false}` with no error field
12+
* - chat caller saw `[chat handler error] undefined`
13+
* Python's `str(e)` round-trips any value to text — the JS port had a real
14+
* divergence.
15+
*
16+
* Each test drives a real `ws.WebSocketServer` (no mocks) and asserts the
17+
* error string the publisher / connection / exposure sends back.
18+
*/
19+
import { describe, it } from 'node:test';
20+
import assert from 'node:assert/strict';
21+
import { WebSocketServer } from 'ws';
22+
import type { AddressInfo } from 'node:net';
23+
import { publish, connect, expose } from '../src/client.js';
24+
25+
function hubUrl(port: number): string {
26+
return `http://127.0.0.1:${port}`;
27+
}
28+
29+
describe('ZhubExposure invoke-request: non-Error throw', () => {
30+
it('surfaces a thrown string verbatim instead of dropping the error field', async () => {
31+
const wss = new WebSocketServer({ port: 0 });
32+
await new Promise<void>((r) => wss.on('listening', r));
33+
const port = (wss.address() as AddressInfo).port;
34+
35+
let reply: { type: string; payload: Record<string, unknown> } | null = null;
36+
const got = new Promise<void>((resolve) => {
37+
wss.on('connection', (ws) => {
38+
ws.on('message', (raw) => {
39+
const env = JSON.parse(raw.toString());
40+
if (env.type === 'register-exposure') {
41+
ws.send(JSON.stringify({
42+
type: 'exposure-registered',
43+
request_id: env.request_id,
44+
payload: { exposure_id: 'ex_t', device_key: 'dx_t', name: 'cam' },
45+
}));
46+
ws.send(JSON.stringify({
47+
type: 'invoke-request',
48+
request_id: 'inv-1',
49+
payload: { capability: 'snap', args: {} },
50+
}));
51+
return;
52+
}
53+
if (env.type === 'invoke-result') {
54+
reply = env;
55+
resolve();
56+
}
57+
});
58+
});
59+
});
60+
61+
const exp = expose({
62+
name: 'cam',
63+
capabilities: {
64+
// eslint-disable-next-line @typescript-eslint/only-throw-error
65+
snap: [{}, () => { throw 'camera offline'; }],
66+
},
67+
hubUrl: hubUrl(port),
68+
});
69+
await got;
70+
await exp.stop();
71+
await new Promise<void>((r) => wss.close(() => r()));
72+
73+
assert.equal(reply!.payload.ok, false);
74+
// Pre-fix this assertion failed: payload.error was undefined → stripped
75+
// from the JSON wire envelope, so the caller saw {ok:false} only.
76+
assert.equal(reply!.payload.error, 'camera offline');
77+
});
78+
79+
it('surfaces a thrown plain object via String(e)', async () => {
80+
const wss = new WebSocketServer({ port: 0 });
81+
await new Promise<void>((r) => wss.on('listening', r));
82+
const port = (wss.address() as AddressInfo).port;
83+
84+
let reply: { type: string; payload: Record<string, unknown> } | null = null;
85+
const got = new Promise<void>((resolve) => {
86+
wss.on('connection', (ws) => {
87+
ws.on('message', (raw) => {
88+
const env = JSON.parse(raw.toString());
89+
if (env.type === 'register-exposure') {
90+
ws.send(JSON.stringify({
91+
type: 'exposure-registered',
92+
request_id: env.request_id,
93+
payload: { exposure_id: 'ex_t', device_key: 'dx_t', name: 'cam' },
94+
}));
95+
ws.send(JSON.stringify({
96+
type: 'invoke-request',
97+
request_id: 'inv-1',
98+
payload: { capability: 'snap', args: {} },
99+
}));
100+
return;
101+
}
102+
if (env.type === 'invoke-result') {
103+
reply = env;
104+
resolve();
105+
}
106+
});
107+
});
108+
});
109+
110+
const exp = expose({
111+
name: 'cam',
112+
capabilities: {
113+
// eslint-disable-next-line @typescript-eslint/only-throw-error
114+
snap: [{}, () => { throw { code: 7 }; }],
115+
},
116+
hubUrl: hubUrl(port),
117+
});
118+
await got;
119+
await exp.stop();
120+
await new Promise<void>((r) => wss.close(() => r()));
121+
122+
assert.equal(reply!.payload.ok, false);
123+
// String({code:7}) === '[object Object]' — readable, never undefined.
124+
assert.equal(reply!.payload.error, '[object Object]');
125+
});
126+
});
127+
128+
describe('ZhubConnection invoke-request: non-Error throw', () => {
129+
it('surfaces a thrown string instead of dropping the error field', async () => {
130+
const wss = new WebSocketServer({ port: 0 });
131+
await new Promise<void>((r) => wss.on('listening', r));
132+
const port = (wss.address() as AddressInfo).port;
133+
134+
let reply: { type: string; payload: Record<string, unknown> } | null = null;
135+
const got = new Promise<void>((resolve) => {
136+
wss.on('connection', (ws) => {
137+
ws.on('message', (raw) => {
138+
const env = JSON.parse(raw.toString());
139+
if (env.type === 'register-connection') {
140+
ws.send(JSON.stringify({
141+
type: 'registered',
142+
request_id: env.request_id,
143+
payload: { connection_id: 'cx_t' },
144+
}));
145+
ws.send(JSON.stringify({
146+
type: 'invoke-request',
147+
request_id: 'inv-1',
148+
payload: { capability: 'do_thing', args: {} },
149+
}));
150+
return;
151+
}
152+
if (env.type === 'invoke-result') {
153+
reply = env;
154+
resolve();
155+
}
156+
});
157+
});
158+
});
159+
160+
const conn = connect({
161+
aiName: 'p',
162+
apiKey: 'zk_test',
163+
hubUrl: hubUrl(port),
164+
capabilities: {
165+
// eslint-disable-next-line @typescript-eslint/only-throw-error
166+
do_thing: [{}, () => { throw 'bad args'; }],
167+
},
168+
});
169+
await got;
170+
await conn.stop();
171+
await new Promise<void>((r) => wss.close(() => r()));
172+
173+
assert.equal(reply!.payload.ok, false);
174+
assert.equal(reply!.payload.error, 'bad args');
175+
});
176+
});
177+
178+
describe('ZhubPublication handleChat: non-Error throw', () => {
179+
it('substitutes a readable string for the thrown value (no `undefined` leak)', async () => {
180+
const wss = new WebSocketServer({ port: 0 });
181+
await new Promise<void>((r) => wss.on('listening', r));
182+
const port = (wss.address() as AddressInfo).port;
183+
184+
let response: { type: string; payload: Record<string, unknown> } | null = null;
185+
const got = new Promise<void>((resolve) => {
186+
wss.on('connection', (ws) => {
187+
ws.send(JSON.stringify({
188+
type: 'registered',
189+
request_id: 'r0',
190+
payload: { name: 'p', base_url: '', api_key: 'zk_test' },
191+
}));
192+
ws.send(JSON.stringify({
193+
type: 'chat-request',
194+
request_id: 'req-1',
195+
payload: { messages: [{ role: 'user', content: 'hi' }], stream: false },
196+
}));
197+
ws.on('message', (raw) => {
198+
const env = JSON.parse(raw.toString());
199+
if (env.type === 'chat-response') {
200+
response = env;
201+
resolve();
202+
}
203+
});
204+
});
205+
});
206+
207+
const pub = publish({
208+
name: 'p',
209+
description: 't',
210+
hubUrl: hubUrl(port),
211+
apiKey: 'zk_test',
212+
// eslint-disable-next-line @typescript-eslint/only-throw-error
213+
chatHandler: () => { throw 'upstream rate limit'; },
214+
});
215+
await got;
216+
await pub.stop();
217+
await new Promise<void>((r) => wss.close(() => r()));
218+
219+
// Pre-fix payload.text === '[chat handler error] undefined'.
220+
assert.equal(response!.payload.text, '[chat handler error] upstream rate limit');
221+
assert.equal(response!.payload.finish_reason, 'error');
222+
});
223+
});

0 commit comments

Comments
 (0)