@runtypelabs/persona 3.33.0 → 3.34.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.
- package/README.md +15 -10
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/index.cjs +3 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +32 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.global.js +191 -190
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +117 -117
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +32 -0
- package/dist/smart-dom-reader.d.ts +32 -0
- package/dist/theme-editor.cjs +13 -13
- package/dist/theme-editor.d.cts +32 -0
- package/dist/theme-editor.d.ts +32 -0
- package/dist/theme-editor.js +14 -14
- package/package.json +3 -3
- package/src/client.test.ts +55 -0
- package/src/client.ts +10 -1
- package/src/codegen.test.ts +0 -14
- package/src/runtime/host-layout.test.ts +51 -9
- package/src/runtime/host-layout.ts +29 -10
- package/src/runtime/init.test.ts +2 -2
- package/src/types.ts +29 -0
- package/src/ui.docked.test.ts +2 -2
- package/src/voice/voice.test.ts +0 -51
- package/src/install-config.test.ts +0 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtypelabs/persona",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.34.1",
|
|
4
4
|
"description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -60,9 +60,9 @@
|
|
|
60
60
|
],
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"@mcp-b/webmcp-polyfill": "^3.0.0",
|
|
63
|
-
"dompurify": "^3.
|
|
63
|
+
"dompurify": "^3.4.10",
|
|
64
64
|
"idiomorph": "^0.7.4",
|
|
65
|
-
"lucide": "^
|
|
65
|
+
"lucide": "^1.18.0",
|
|
66
66
|
"marked": "^12.0.2",
|
|
67
67
|
"partial-json": "^0.1.7",
|
|
68
68
|
"zod": "^3.22.4"
|
package/src/client.test.ts
CHANGED
|
@@ -4121,3 +4121,58 @@ describe('AgentWidgetClient - non-client-token paths always send full clientTool
|
|
|
4121
4121
|
}
|
|
4122
4122
|
});
|
|
4123
4123
|
});
|
|
4124
|
+
|
|
4125
|
+
describe('AgentWidgetClient - Feedback request builder', () => {
|
|
4126
|
+
const INIT_RESPONSE = {
|
|
4127
|
+
sessionId: 'sess_123',
|
|
4128
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
4129
|
+
flow: {},
|
|
4130
|
+
config: { welcomeMessage: 'hi', placeholder: 'type…', theme: {} },
|
|
4131
|
+
};
|
|
4132
|
+
|
|
4133
|
+
function mockInitAndFeedback() {
|
|
4134
|
+
const feedbackBodies: Array<Record<string, unknown>> = [];
|
|
4135
|
+
global.fetch = vi.fn().mockImplementation(async (url: string, options: { body: string }) => {
|
|
4136
|
+
if (typeof url === 'string' && url.endsWith('/v1/client/feedback')) {
|
|
4137
|
+
feedbackBodies.push(JSON.parse(options.body));
|
|
4138
|
+
return { ok: true, status: 200, json: async () => ({}) };
|
|
4139
|
+
}
|
|
4140
|
+
// init request
|
|
4141
|
+
return { ok: true, status: 200, json: async () => INIT_RESPONSE };
|
|
4142
|
+
});
|
|
4143
|
+
return feedbackBodies;
|
|
4144
|
+
}
|
|
4145
|
+
|
|
4146
|
+
it('includes the client token in the feedback request body', async () => {
|
|
4147
|
+
const feedbackBodies = mockInitAndFeedback();
|
|
4148
|
+
const client = new AgentWidgetClient({
|
|
4149
|
+
apiUrl: 'http://localhost:8000',
|
|
4150
|
+
clientToken: 'ct_live_abc123',
|
|
4151
|
+
});
|
|
4152
|
+
await client.initSession();
|
|
4153
|
+
|
|
4154
|
+
await client.sendFeedback({ sessionId: 'sess_123', messageId: 'm1', type: 'upvote' });
|
|
4155
|
+
|
|
4156
|
+
expect(feedbackBodies).toHaveLength(1);
|
|
4157
|
+
expect(feedbackBodies[0]).toEqual({
|
|
4158
|
+
sessionId: 'sess_123',
|
|
4159
|
+
messageId: 'm1',
|
|
4160
|
+
type: 'upvote',
|
|
4161
|
+
token: 'ct_live_abc123',
|
|
4162
|
+
});
|
|
4163
|
+
});
|
|
4164
|
+
|
|
4165
|
+
it('sends the token on csat/nps submissions too', async () => {
|
|
4166
|
+
const feedbackBodies = mockInitAndFeedback();
|
|
4167
|
+
const client = new AgentWidgetClient({
|
|
4168
|
+
apiUrl: 'http://localhost:8000',
|
|
4169
|
+
clientToken: 'ct_live_xyz789',
|
|
4170
|
+
});
|
|
4171
|
+
await client.initSession();
|
|
4172
|
+
|
|
4173
|
+
await client.sendFeedback({ sessionId: 'sess_123', type: 'csat', rating: 5 });
|
|
4174
|
+
|
|
4175
|
+
expect(feedbackBodies[0].token).toBe('ct_live_xyz789');
|
|
4176
|
+
expect(feedbackBodies[0].rating).toBe(5);
|
|
4177
|
+
});
|
|
4178
|
+
});
|
package/src/client.ts
CHANGED
|
@@ -482,12 +482,21 @@ export class AgentWidgetClient {
|
|
|
482
482
|
console.debug("[AgentWidgetClient] sending feedback", feedback);
|
|
483
483
|
}
|
|
484
484
|
|
|
485
|
+
// Scope the feedback request to the caller's client token, sourced the same
|
|
486
|
+
// way as the chat/init requests. sendFeedback is client-token-mode only
|
|
487
|
+
// (guarded above), so clientToken is always present here and an API key can
|
|
488
|
+
// never leak into the body. Left undefined only when the embed has none.
|
|
489
|
+
const requestBody = {
|
|
490
|
+
...feedback,
|
|
491
|
+
...(this.config.clientToken && { token: this.config.clientToken }),
|
|
492
|
+
};
|
|
493
|
+
|
|
485
494
|
const response = await fetch(this.getFeedbackApiUrl(), {
|
|
486
495
|
method: 'POST',
|
|
487
496
|
headers: {
|
|
488
497
|
'Content-Type': 'application/json',
|
|
489
498
|
},
|
|
490
|
-
body: JSON.stringify(
|
|
499
|
+
body: JSON.stringify(requestBody),
|
|
491
500
|
});
|
|
492
501
|
|
|
493
502
|
if (!response.ok) {
|
package/src/codegen.test.ts
CHANGED
|
@@ -17,20 +17,6 @@ describe("codegen subpath", () => {
|
|
|
17
17
|
expect(fromSubpath).toBe(fromInternal);
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
-
it("produces identical output via the subpath for every format", () => {
|
|
21
|
-
const formats = [
|
|
22
|
-
"esm",
|
|
23
|
-
"script-installer",
|
|
24
|
-
"script-manual",
|
|
25
|
-
"script-advanced",
|
|
26
|
-
"react-component",
|
|
27
|
-
"react-advanced",
|
|
28
|
-
] as const;
|
|
29
|
-
for (const format of formats) {
|
|
30
|
-
expect(fromSubpath(config, format)).toBe(fromInternal(config, format));
|
|
31
|
-
}
|
|
32
|
-
});
|
|
33
|
-
|
|
34
20
|
it("pins the installer CDN url to the package version (not @latest)", () => {
|
|
35
21
|
const code = fromSubpath(config, "script-installer");
|
|
36
22
|
expect(code).toContain(`@runtypelabs/persona@${VERSION}/dist/install.global.js`);
|
|
@@ -108,7 +108,7 @@ describe("createWidgetHostLayout docked", () => {
|
|
|
108
108
|
layout.destroy();
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
-
it("push reveal
|
|
111
|
+
it("push reveal offsets a track with margin (no transform); main column width stays fixed in px", () => {
|
|
112
112
|
const parent = document.createElement("div");
|
|
113
113
|
parent.style.width = "800px";
|
|
114
114
|
document.body.appendChild(parent);
|
|
@@ -132,15 +132,20 @@ describe("createWidgetHostLayout docked", () => {
|
|
|
132
132
|
expect(shell.dataset.personaDockReveal).toBe("push");
|
|
133
133
|
expect(contentSlot?.style.width).toBe("800px");
|
|
134
134
|
expect(pushTrack?.style.width).toBe("1120px");
|
|
135
|
-
expect(pushTrack?.style.
|
|
135
|
+
expect(pushTrack?.style.marginLeft).toBe("0px");
|
|
136
|
+
// The track must NOT carry a transform: a transformed ancestor becomes the
|
|
137
|
+
// containing block for `position: fixed` host chrome and pushes it
|
|
138
|
+
// off-screen (regression guard, see host-layout.ts push branch).
|
|
139
|
+
expect(pushTrack?.style.transform).toBe("");
|
|
136
140
|
|
|
137
141
|
layout.syncWidgetState({ open: true, launcherEnabled: true });
|
|
138
|
-
expect(pushTrack?.style.
|
|
142
|
+
expect(pushTrack?.style.marginLeft).toBe("-320px");
|
|
143
|
+
expect(pushTrack?.style.transform).toBe("");
|
|
139
144
|
|
|
140
145
|
layout.destroy();
|
|
141
146
|
});
|
|
142
147
|
|
|
143
|
-
it("push reveal on the left uses negative
|
|
148
|
+
it("push reveal on the left uses negative margin when closed", () => {
|
|
144
149
|
const parent = document.createElement("div");
|
|
145
150
|
parent.style.width = "600px";
|
|
146
151
|
document.body.appendChild(parent);
|
|
@@ -159,10 +164,12 @@ describe("createWidgetHostLayout docked", () => {
|
|
|
159
164
|
layout.updateConfig({ launcher: dockConfig });
|
|
160
165
|
|
|
161
166
|
const pushTrack = shell.querySelector<HTMLElement>('[data-persona-dock-role="push-track"]');
|
|
162
|
-
expect(pushTrack?.style.
|
|
167
|
+
expect(pushTrack?.style.marginLeft).toBe("-200px");
|
|
168
|
+
expect(pushTrack?.style.transform).toBe("");
|
|
163
169
|
|
|
164
170
|
layout.syncWidgetState({ open: true, launcherEnabled: true });
|
|
165
|
-
expect(pushTrack?.style.
|
|
171
|
+
expect(pushTrack?.style.marginLeft).toBe("0px");
|
|
172
|
+
expect(pushTrack?.style.transform).toBe("");
|
|
166
173
|
|
|
167
174
|
layout.destroy();
|
|
168
175
|
});
|
|
@@ -219,9 +226,9 @@ describe("createWidgetHostLayout docked", () => {
|
|
|
219
226
|
}
|
|
220
227
|
});
|
|
221
228
|
|
|
222
|
-
it("clamps push and overlay dock slots without sticky (
|
|
223
|
-
// push: the slot
|
|
224
|
-
//
|
|
229
|
+
it("clamps push and overlay dock slots without sticky (in-flow/absolute contexts)", () => {
|
|
230
|
+
// push: the slot is an in-flow `position: relative` column — max-height cap
|
|
231
|
+
// only, no sticky. overlay: keeps absolute positioning.
|
|
225
232
|
const cases = [
|
|
226
233
|
{ reveal: "push" as const, position: "relative" },
|
|
227
234
|
{ reveal: "overlay" as const, position: "absolute" },
|
|
@@ -390,6 +397,41 @@ describe("createWidgetHostLayout docked", () => {
|
|
|
390
397
|
});
|
|
391
398
|
});
|
|
392
399
|
|
|
400
|
+
it("resets the push track margin when a desktop push offset enters mobile fullscreen", () => {
|
|
401
|
+
const parent = document.createElement("div");
|
|
402
|
+
parent.style.width = "800px";
|
|
403
|
+
document.body.appendChild(parent);
|
|
404
|
+
const target = document.createElement("div");
|
|
405
|
+
parent.appendChild(target);
|
|
406
|
+
|
|
407
|
+
const layout = createWidgetHostLayout(target, {
|
|
408
|
+
launcher: {
|
|
409
|
+
mountMode: "docked",
|
|
410
|
+
autoExpand: false,
|
|
411
|
+
dock: { width: "320px", reveal: "push" },
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
const pushTrack = layout.shell?.querySelector<HTMLElement>(
|
|
415
|
+
'[data-persona-dock-role="push-track"]',
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
// Desktop, expanded: track carries a negative margin offset.
|
|
419
|
+
withInnerWidth(800, () => {
|
|
420
|
+
layout.syncWidgetState({ open: true, launcherEnabled: true });
|
|
421
|
+
});
|
|
422
|
+
expect(pushTrack?.style.marginLeft).toBe("-320px");
|
|
423
|
+
|
|
424
|
+
// Same layout falls into mobile fullscreen (viewport resize): the stale
|
|
425
|
+
// margin must be cleared or the width:100% track renders 320px off-screen.
|
|
426
|
+
withInnerWidth(500, () => {
|
|
427
|
+
window.dispatchEvent(new Event("resize"));
|
|
428
|
+
});
|
|
429
|
+
expect(layout.shell?.dataset.personaDockMobileFullscreen).toBe("true");
|
|
430
|
+
expect(pushTrack?.style.marginLeft).toBe("0px");
|
|
431
|
+
|
|
432
|
+
layout.destroy();
|
|
433
|
+
});
|
|
434
|
+
|
|
393
435
|
it("does not use fixed fullscreen above mobile breakpoint", () => {
|
|
394
436
|
withInnerWidth(800, () => {
|
|
395
437
|
const parent = document.createElement("div");
|
|
@@ -53,9 +53,10 @@ const applyDockSlotMaxHeight = (
|
|
|
53
53
|
* when the surrounding page is taller than the screen (e.g. a missing height
|
|
54
54
|
* chain or a deliberately scrolling page). With a properly sized shell it
|
|
55
55
|
* behaves exactly like the previous `position: relative`. Not used for push:
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
56
|
+
* push gets the max-height cap only, like overlay, since the dock slot is an
|
|
57
|
+
* in-flow `position: relative` column there rather than a viewport-pinned one.
|
|
58
|
+
* (The push track itself is offset with `margin-left`, not a transform, so it
|
|
59
|
+
* no longer establishes a containing block for fixed/sticky descendants.)
|
|
59
60
|
*/
|
|
60
61
|
const applyInFlowDockSlotPosition = (
|
|
61
62
|
dockSlot: HTMLElement,
|
|
@@ -151,6 +152,7 @@ const clearPushTrackStyles = (pushTrack: HTMLElement): void => {
|
|
|
151
152
|
pushTrack.style.alignItems = "";
|
|
152
153
|
pushTrack.style.transition = "";
|
|
153
154
|
pushTrack.style.transform = "";
|
|
155
|
+
pushTrack.style.marginLeft = "";
|
|
154
156
|
};
|
|
155
157
|
|
|
156
158
|
const resetContentSlotFlexSizing = (contentSlot: HTMLElement): void => {
|
|
@@ -294,6 +296,11 @@ const applyDockStyles = (
|
|
|
294
296
|
pushTrack.style.flex = "1 1 auto";
|
|
295
297
|
pushTrack.style.alignItems = "stretch";
|
|
296
298
|
pushTrack.style.transform = "none";
|
|
299
|
+
// Reset the desktop push offset: this fullscreen path applies styles
|
|
300
|
+
// inline without going through clearPushTrackStyles, so a stale negative
|
|
301
|
+
// marginLeft from a prior expanded desktop render would shift the now
|
|
302
|
+
// width:100% track off-screen on mobile.
|
|
303
|
+
pushTrack.style.marginLeft = "0";
|
|
297
304
|
pushTrack.style.transition = "none";
|
|
298
305
|
contentSlot.style.flex = "1 1 auto";
|
|
299
306
|
contentSlot.style.width = "100%";
|
|
@@ -359,15 +366,24 @@ const applyDockStyles = (
|
|
|
359
366
|
|
|
360
367
|
const panelPx = parseDockWidthToPx(dock.width, shell.clientWidth);
|
|
361
368
|
const contentPx = Math.max(0, shell.clientWidth);
|
|
362
|
-
const dockTransition = dock.animate ? "
|
|
363
|
-
|
|
369
|
+
const dockTransition = dock.animate ? "margin-left 180ms ease" : "none";
|
|
370
|
+
// Slide the wide track with a negative left margin rather than a CSS
|
|
371
|
+
// transform. A `transform` (even `translateX(0)`) turns the push track into
|
|
372
|
+
// the containing block for any `position: fixed` descendant, so host pages
|
|
373
|
+
// that render viewport-fixed chrome inside the pushed content (e.g. the
|
|
374
|
+
// dashboard editor's `fixed top-0 right-0` toolbar) resolve `right: 0`
|
|
375
|
+
// against the track's right edge — `panelPx` past the viewport, off-screen.
|
|
376
|
+
// `margin-left` produces the identical visual offset (the track is clipped
|
|
377
|
+
// by the overflow:hidden shell) without establishing a containing block for
|
|
378
|
+
// fixed OR absolutely-positioned descendants.
|
|
379
|
+
const marginOffsetPx =
|
|
364
380
|
dock.side === "right"
|
|
365
381
|
? expanded
|
|
366
|
-
?
|
|
367
|
-
:
|
|
382
|
+
? -panelPx
|
|
383
|
+
: 0
|
|
368
384
|
: expanded
|
|
369
|
-
?
|
|
370
|
-
:
|
|
385
|
+
? 0
|
|
386
|
+
: -panelPx;
|
|
371
387
|
|
|
372
388
|
pushTrack.style.display = "flex";
|
|
373
389
|
pushTrack.style.flexDirection = "row";
|
|
@@ -378,7 +394,10 @@ const applyDockStyles = (
|
|
|
378
394
|
pushTrack.style.height = "100%";
|
|
379
395
|
pushTrack.style.width = `${contentPx + panelPx}px`;
|
|
380
396
|
pushTrack.style.transition = dockTransition;
|
|
381
|
-
pushTrack.style.
|
|
397
|
+
pushTrack.style.marginLeft = `${marginOffsetPx}px`;
|
|
398
|
+
// Defensively clear any transform a previous reveal mode may have set —
|
|
399
|
+
// leaving one would re-establish the fixed-position containing block.
|
|
400
|
+
pushTrack.style.transform = "";
|
|
382
401
|
|
|
383
402
|
contentSlot.style.flex = "0 0 auto";
|
|
384
403
|
contentSlot.style.flexGrow = "0";
|
package/src/runtime/init.test.ts
CHANGED
|
@@ -424,11 +424,11 @@ describe("initAgentWidget docked mode", () => {
|
|
|
424
424
|
const panelSlot = shell.querySelector<HTMLElement>('[data-persona-dock-role="panel"]')!;
|
|
425
425
|
expect(pushTrack).not.toBeNull();
|
|
426
426
|
expect(panelSlot.style.width).toBe("400px");
|
|
427
|
-
expect(pushTrack?.style.
|
|
427
|
+
expect(pushTrack?.style.marginLeft).toBe("-400px");
|
|
428
428
|
|
|
429
429
|
handle.close();
|
|
430
430
|
expect(panelSlot.style.width).toBe("400px");
|
|
431
|
-
expect(pushTrack?.style.
|
|
431
|
+
expect(pushTrack?.style.marginLeft).toBe("0px");
|
|
432
432
|
|
|
433
433
|
handle.destroy();
|
|
434
434
|
wrapper.remove();
|
package/src/types.ts
CHANGED
|
@@ -145,12 +145,41 @@ export type AgentToolsConfig = {
|
|
|
145
145
|
mcpServers?: Array<Record<string, unknown>>;
|
|
146
146
|
/** Maximum number of tool invocations per execution */
|
|
147
147
|
maxToolCalls?: number;
|
|
148
|
+
/** How the model is steered toward tools: let it decide, force a call, or disable */
|
|
149
|
+
toolCallStrategy?: "auto" | "required" | "none";
|
|
150
|
+
/** Per-tool invocation limits / requirements keyed by tool name */
|
|
151
|
+
perToolLimits?: Record<string, { maxCalls?: number; required?: boolean }>;
|
|
148
152
|
/** Tool approval configuration for human-in-the-loop workflows */
|
|
149
153
|
approval?: {
|
|
150
154
|
/** Tool names/patterns to require approval for, or true for all tools */
|
|
151
155
|
require: string[] | boolean;
|
|
152
156
|
/** Approval timeout in milliseconds (default: 300000 / 5 minutes) */
|
|
153
157
|
timeout?: number;
|
|
158
|
+
/** Ask the agent to state its intent alongside approval requests (default: true) */
|
|
159
|
+
requestReason?: boolean;
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Enables the synthesized `spawn_subagent` tool: the model can spin up
|
|
163
|
+
* ad-hoc child agents at runtime, restricted to `toolPool` (tool IDs /
|
|
164
|
+
* runtime-tool names already granted to the parent agent).
|
|
165
|
+
*/
|
|
166
|
+
subagentConfig?: {
|
|
167
|
+
toolPool: string[];
|
|
168
|
+
defaultMaxTurns?: number;
|
|
169
|
+
maxTurnsLimit?: number;
|
|
170
|
+
maxSpawnsPerRun?: number;
|
|
171
|
+
defaultModel?: string;
|
|
172
|
+
allowNesting?: boolean;
|
|
173
|
+
defaultTimeoutMs?: number;
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* Enables the synthesized `code_mode` tool: the model writes JS that calls
|
|
177
|
+
* pool tools inside a sandbox instead of issuing individual tool calls.
|
|
178
|
+
*/
|
|
179
|
+
codeModeConfig?: {
|
|
180
|
+
toolPool: string[];
|
|
181
|
+
description?: string;
|
|
182
|
+
timeoutMs?: number;
|
|
154
183
|
};
|
|
155
184
|
};
|
|
156
185
|
|
package/src/ui.docked.test.ts
CHANGED
|
@@ -286,11 +286,11 @@ describe("createAgentExperience docked mode", () => {
|
|
|
286
286
|
const closeButton = mount.querySelector<HTMLButtonElement>('[aria-label="Close chat"]');
|
|
287
287
|
|
|
288
288
|
expect(pushTrack).not.toBeNull();
|
|
289
|
-
expect(pushTrack?.style.
|
|
289
|
+
expect(pushTrack?.style.marginLeft).toBe("-420px");
|
|
290
290
|
|
|
291
291
|
closeButton!.click();
|
|
292
292
|
|
|
293
|
-
expect(pushTrack?.style.
|
|
293
|
+
expect(pushTrack?.style.marginLeft).toBe("0px");
|
|
294
294
|
|
|
295
295
|
openUnsub();
|
|
296
296
|
closeUnsub();
|
package/src/voice/voice.test.ts
CHANGED
|
@@ -32,58 +32,7 @@ function mockBrowserSupport(supported: boolean) {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
// Note: TypeScript interfaces don't exist at runtime, so we can't test them directly
|
|
36
|
-
// We test the concrete implementations instead
|
|
37
|
-
|
|
38
|
-
describe('RuntypeVoiceProvider', () => {
|
|
39
|
-
it('should create instance with valid config', () => {
|
|
40
|
-
const config = {
|
|
41
|
-
agentId: 'test-agent',
|
|
42
|
-
clientToken: 'test-token',
|
|
43
|
-
host: 'localhost:8787',
|
|
44
|
-
voiceId: 'rachel'
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const provider = new RuntypeVoiceProvider(config);
|
|
48
|
-
expect(provider).toBeInstanceOf(RuntypeVoiceProvider);
|
|
49
|
-
expect(provider.type).toBe('runtype');
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('should have correct methods', () => {
|
|
53
|
-
const config = {
|
|
54
|
-
agentId: 'test-agent',
|
|
55
|
-
clientToken: 'test-token'
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
const provider = new RuntypeVoiceProvider(config);
|
|
59
|
-
expect(typeof provider.connect).toBe('function');
|
|
60
|
-
expect(typeof provider.disconnect).toBe('function');
|
|
61
|
-
expect(typeof provider.startListening).toBe('function');
|
|
62
|
-
expect(typeof provider.stopListening).toBe('function');
|
|
63
|
-
expect(typeof provider.onResult).toBe('function');
|
|
64
|
-
expect(typeof provider.onError).toBe('function');
|
|
65
|
-
expect(typeof provider.onStatusChange).toBe('function');
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
35
|
describe('BrowserVoiceProvider', () => {
|
|
70
|
-
it('should create instance with default config', () => {
|
|
71
|
-
const provider = new BrowserVoiceProvider();
|
|
72
|
-
expect(provider).toBeInstanceOf(BrowserVoiceProvider);
|
|
73
|
-
expect(provider.type).toBe('browser');
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('should have correct methods', () => {
|
|
77
|
-
const provider = new BrowserVoiceProvider();
|
|
78
|
-
expect(typeof provider.connect).toBe('function');
|
|
79
|
-
expect(typeof provider.disconnect).toBe('function');
|
|
80
|
-
expect(typeof provider.startListening).toBe('function');
|
|
81
|
-
expect(typeof provider.stopListening).toBe('function');
|
|
82
|
-
expect(typeof provider.onResult).toBe('function');
|
|
83
|
-
expect(typeof provider.onError).toBe('function');
|
|
84
|
-
expect(typeof provider.onStatusChange).toBe('function');
|
|
85
|
-
});
|
|
86
|
-
|
|
87
36
|
it('should check browser support', () => {
|
|
88
37
|
// Test supported
|
|
89
38
|
mockBrowserSupport(true);
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
|
|
3
|
-
describe("prototype pollution guard", () => {
|
|
4
|
-
it("strips __proto__, constructor, and prototype from parsed config", () => {
|
|
5
|
-
// Simulates the destructuring pattern used in install.ts
|
|
6
|
-
const malicious = JSON.parse(
|
|
7
|
-
'{"config":{"apiUrl":"http://localhost"},"__proto__":{"polluted":true},"constructor":{"polluted":true},"prototype":{"polluted":true}}'
|
|
8
|
-
);
|
|
9
|
-
|
|
10
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
11
|
-
const { __proto__: _a, constructor: _b, prototype: _c, ...safeConfig } = malicious;
|
|
12
|
-
|
|
13
|
-
const target: Record<string, unknown> = {};
|
|
14
|
-
Object.assign(target, safeConfig);
|
|
15
|
-
|
|
16
|
-
// The dangerous keys should not be on the target
|
|
17
|
-
expect(Object.keys(target)).toEqual(["config"]);
|
|
18
|
-
expect((target as any).__proto__).toBe(Object.prototype); // normal prototype, not polluted
|
|
19
|
-
expect((target as any).constructor).toBe(Object); // normal constructor
|
|
20
|
-
|
|
21
|
-
// Global prototype should not be polluted
|
|
22
|
-
expect(({} as any).polluted).toBeUndefined();
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("preserves safe config properties", () => {
|
|
26
|
-
const parsed = JSON.parse(
|
|
27
|
-
'{"config":{"apiUrl":"http://localhost"},"clientToken":"tok_123","flowId":"flow-1"}'
|
|
28
|
-
);
|
|
29
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
30
|
-
const { __proto__: _a, constructor: _b, prototype: _c, ...safeConfig } = parsed;
|
|
31
|
-
|
|
32
|
-
expect(safeConfig).toEqual({
|
|
33
|
-
config: { apiUrl: "http://localhost" },
|
|
34
|
-
clientToken: "tok_123",
|
|
35
|
-
flowId: "flow-1",
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
});
|