create-spatius-app 0.1.0-beta.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/LICENSE +21 -0
- package/README.md +57 -0
- package/dist/cli.js +9046 -0
- package/dist/cli.js.map +1 -0
- package/dist/templates.js +2856 -0
- package/dist/templates.js.map +1 -0
- package/package.json +79 -0
- package/schemas/result-v1.schema.json +129 -0
- package/schemas/result-v2.schema.json +146 -0
- package/templates/cloudflare-livekit/.editorconfig +15 -0
- package/templates/cloudflare-livekit/.prettierignore +6 -0
- package/templates/cloudflare-livekit/.prettierrc.json +4 -0
- package/templates/cloudflare-livekit/AGENTS.md +41 -0
- package/templates/cloudflare-livekit/README.md +37 -0
- package/templates/cloudflare-livekit/agent/Dockerfile.pip +31 -0
- package/templates/cloudflare-livekit/agent/Dockerfile.uv +29 -0
- package/templates/cloudflare-livekit/agent/README.md +84 -0
- package/templates/cloudflare-livekit/agent/dockerignore +11 -0
- package/templates/cloudflare-livekit/agent/env.example +8 -0
- package/templates/cloudflare-livekit/agent/pyproject.toml +21 -0
- package/templates/cloudflare-livekit/agent/python-version +1 -0
- package/templates/cloudflare-livekit/agent/src/__init__.py +1 -0
- package/templates/cloudflare-livekit/agent/src/agent.py +97 -0
- package/templates/cloudflare-livekit/agent/src/dispatch_metadata.py +64 -0
- package/templates/cloudflare-livekit/agent/tests/test_agent_startup.py +211 -0
- package/templates/cloudflare-livekit/agent/tests/test_dispatch_metadata.py +97 -0
- package/templates/cloudflare-livekit/agent/uv.lock +2272 -0
- package/templates/cloudflare-livekit/components.json +23 -0
- package/templates/cloudflare-livekit/contracts/agent-dispatch-metadata.schema.json +40 -0
- package/templates/cloudflare-livekit/dev.vars.example +12 -0
- package/templates/cloudflare-livekit/eslint.config.mjs +41 -0
- package/templates/cloudflare-livekit/gitignore +30 -0
- package/templates/cloudflare-livekit/index.html +17 -0
- package/templates/cloudflare-livekit/npmrc +4 -0
- package/templates/cloudflare-livekit/package-lock.json +6514 -0
- package/templates/cloudflare-livekit/package.json +66 -0
- package/templates/cloudflare-livekit/playwright.config.ts +29 -0
- package/templates/cloudflare-livekit/pnpm-lock.yaml +4012 -0
- package/templates/cloudflare-livekit/pnpm-workspace.yaml +8 -0
- package/templates/cloudflare-livekit/scripts/agent-runtime.mjs +129 -0
- package/templates/cloudflare-livekit/scripts/command.mjs +52 -0
- package/templates/cloudflare-livekit/scripts/dev-agent.mjs +77 -0
- package/templates/cloudflare-livekit/scripts/dev-web.mjs +18 -0
- package/templates/cloudflare-livekit/scripts/managed-process.mjs +144 -0
- package/templates/cloudflare-livekit/scripts/process-tree.mjs +111 -0
- package/templates/cloudflare-livekit/scripts/run-agent-python.mjs +25 -0
- package/templates/cloudflare-livekit/tests/dev-agent.test.mjs +327 -0
- package/templates/cloudflare-livekit/tests/dev-fixtures.mjs +235 -0
- package/templates/cloudflare-livekit/tests/dev-supervision.test.mjs +203 -0
- package/templates/cloudflare-livekit/tests/e2e/conversation.spec.ts +376 -0
- package/templates/cloudflare-livekit/tests/e2e/fake-avatar.ts +24 -0
- package/templates/cloudflare-livekit/tests/e2e/fake-components.tsx +173 -0
- package/templates/cloudflare-livekit/tests/e2e/fake-livekit.ts +123 -0
- package/templates/cloudflare-livekit/tests/e2e/harness.html +12 -0
- package/templates/cloudflare-livekit/tests/e2e/harness.tsx +1 -0
- package/templates/cloudflare-livekit/tests/e2e/vite.config.ts +48 -0
- package/templates/cloudflare-livekit/tests/web/api.test.ts +64 -0
- package/templates/cloudflare-livekit/tests/web/app.test.tsx +125 -0
- package/templates/cloudflare-livekit/tests/web/audio-visualizer.test.tsx +53 -0
- package/templates/cloudflare-livekit/tests/web/avatar-session.test.ts +119 -0
- package/templates/cloudflare-livekit/tests/web/components.test.tsx +152 -0
- package/templates/cloudflare-livekit/tests/web/conversation.test.tsx +76 -0
- package/templates/cloudflare-livekit/tests/web/session-attempt.test.ts +178 -0
- package/templates/cloudflare-livekit/tsconfig.json +9 -0
- package/templates/cloudflare-livekit/tsconfig.node.json +23 -0
- package/templates/cloudflare-livekit/tsconfig.web.json +26 -0
- package/templates/cloudflare-livekit/tsconfig.worker.json +22 -0
- package/templates/cloudflare-livekit/vite.config.ts +49 -0
- package/templates/cloudflare-livekit/vitest.config.ts +11 -0
- package/templates/cloudflare-livekit/web/App.tsx +176 -0
- package/templates/cloudflare-livekit/web/api.ts +69 -0
- package/templates/cloudflare-livekit/web/assets/FONT-PROVENANCE.md +8 -0
- package/templates/cloudflare-livekit/web/assets/OFL.txt +92 -0
- package/templates/cloudflare-livekit/web/assets/inter-latin.woff2 +0 -0
- package/templates/cloudflare-livekit/web/avatar-session.ts +67 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/LICENSE +201 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/PROVENANCE.md +32 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-audio-visualizer-bar.tsx +43 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-chat-input.tsx +78 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-chat-transcript.tsx +145 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-disconnect-button.tsx +27 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-session-provider.tsx +29 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-track-toggle.tsx +51 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/start-audio-button.tsx +13 -0
- package/templates/cloudflare-livekit/web/components/conversation.tsx +352 -0
- package/templates/cloudflare-livekit/web/components/icons.tsx +39 -0
- package/templates/cloudflare-livekit/web/components/transcript-panel.tsx +12 -0
- package/templates/cloudflare-livekit/web/main.tsx +17 -0
- package/templates/cloudflare-livekit/web/session-attempt.ts +110 -0
- package/templates/cloudflare-livekit/web/styles.css +571 -0
- package/templates/cloudflare-livekit/worker/dispatch-metadata.ts +22 -0
- package/templates/cloudflare-livekit/worker/index.test.ts +152 -0
- package/templates/cloudflare-livekit/worker/index.ts +61 -0
- package/templates/cloudflare-livekit/worker/session.ts +103 -0
- package/templates/cloudflare-livekit/worker-configuration.d.ts +17184 -0
- package/templates/cloudflare-livekit/wrangler.jsonc +27 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useState,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import type {
|
|
10
|
+
ReceivedMessage,
|
|
11
|
+
UseSessionReturn,
|
|
12
|
+
} from '@livekit/components-react';
|
|
13
|
+
import { Room, ConnectionState } from './fake-livekit.js';
|
|
14
|
+
|
|
15
|
+
function useUpdates() {
|
|
16
|
+
const [revision, setRevision] = useState(0);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const update = () => setRevision((value) => value + 1);
|
|
19
|
+
window.addEventListener('fixture:update', update);
|
|
20
|
+
return () => window.removeEventListener('fixture:update', update);
|
|
21
|
+
}, []);
|
|
22
|
+
return revision;
|
|
23
|
+
}
|
|
24
|
+
type FixtureSession = {
|
|
25
|
+
room: Room;
|
|
26
|
+
start: () => Promise<void>;
|
|
27
|
+
end: () => Promise<void>;
|
|
28
|
+
isConnected: boolean;
|
|
29
|
+
connectionState: string;
|
|
30
|
+
};
|
|
31
|
+
const Context = createContext<FixtureSession | null>(null);
|
|
32
|
+
export function useSession(
|
|
33
|
+
tokenSource: { fetch: () => Promise<unknown> },
|
|
34
|
+
options: { room: Room },
|
|
35
|
+
) {
|
|
36
|
+
useUpdates();
|
|
37
|
+
const { room } = options;
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
void tokenSource.fetch();
|
|
40
|
+
}, [tokenSource]);
|
|
41
|
+
const actions = useMemo(
|
|
42
|
+
() => ({
|
|
43
|
+
start: async () => {
|
|
44
|
+
await tokenSource.fetch();
|
|
45
|
+
await room.connect();
|
|
46
|
+
},
|
|
47
|
+
end: async () => {
|
|
48
|
+
await tokenSource.fetch();
|
|
49
|
+
await room.disconnect();
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
[room, tokenSource],
|
|
53
|
+
);
|
|
54
|
+
return {
|
|
55
|
+
...actions,
|
|
56
|
+
room,
|
|
57
|
+
connectionState: room.state,
|
|
58
|
+
isConnected: room.state === ConnectionState.Connected,
|
|
59
|
+
} as unknown as UseSessionReturn;
|
|
60
|
+
}
|
|
61
|
+
export function SessionProvider({
|
|
62
|
+
session,
|
|
63
|
+
children,
|
|
64
|
+
}: {
|
|
65
|
+
session: FixtureSession;
|
|
66
|
+
children: ReactNode;
|
|
67
|
+
}) {
|
|
68
|
+
return <Context value={session}>{children}</Context>;
|
|
69
|
+
}
|
|
70
|
+
export function useSessionContext() {
|
|
71
|
+
const session = useContext(Context);
|
|
72
|
+
if (!session) throw new Error('missing context');
|
|
73
|
+
return session;
|
|
74
|
+
}
|
|
75
|
+
export function RoomAudioRenderer() {
|
|
76
|
+
return <audio data-testid="remote-audio-renderer" />;
|
|
77
|
+
}
|
|
78
|
+
export function useAgent() {
|
|
79
|
+
const session = useSessionContext();
|
|
80
|
+
return {
|
|
81
|
+
name: 'Test agent',
|
|
82
|
+
internal: { agentParticipant: null, workerParticipant: null },
|
|
83
|
+
state:
|
|
84
|
+
session.connectionState === ConnectionState.Reconnecting
|
|
85
|
+
? 'disconnected'
|
|
86
|
+
: session.isConnected
|
|
87
|
+
? window.__fixture.pendingAgent
|
|
88
|
+
? 'initializing'
|
|
89
|
+
: 'listening'
|
|
90
|
+
: 'connecting',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export function useLocalParticipant({ room }: { room: Room }) {
|
|
94
|
+
useUpdates();
|
|
95
|
+
return {
|
|
96
|
+
isMicrophoneEnabled: room.localParticipant.isMicrophoneEnabled,
|
|
97
|
+
microphoneTrack: undefined,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export function useMultibandTrackVolume() {
|
|
101
|
+
useUpdates();
|
|
102
|
+
const session = useSessionContext();
|
|
103
|
+
return session.room.localParticipant.isMicrophoneEnabled
|
|
104
|
+
? [0.2, 0.6, 0.9, 0.5, 0.3]
|
|
105
|
+
: [0, 0, 0, 0, 0];
|
|
106
|
+
}
|
|
107
|
+
export function useMediaDeviceSelect({ room }: { room: Room }) {
|
|
108
|
+
useUpdates();
|
|
109
|
+
return {
|
|
110
|
+
devices: [
|
|
111
|
+
{ deviceId: 'default', label: 'Default microphone' },
|
|
112
|
+
{ deviceId: 'headset', label: 'Headset microphone' },
|
|
113
|
+
],
|
|
114
|
+
activeDeviceId: room.activeDeviceId,
|
|
115
|
+
setActiveMediaDevice: async (id: string) => {
|
|
116
|
+
room.activeDeviceId = id;
|
|
117
|
+
window.__fixture.events.push(`device:${id}`);
|
|
118
|
+
room.update();
|
|
119
|
+
await Promise.resolve();
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export function useStartAudio({ room }: { room: Room }) {
|
|
124
|
+
useUpdates();
|
|
125
|
+
return {
|
|
126
|
+
mergedProps: {
|
|
127
|
+
style: { display: room.audioUnlocked ? 'none' : 'inline-flex' },
|
|
128
|
+
onClick: () => {
|
|
129
|
+
window.__fixture.blockedAudio = false;
|
|
130
|
+
void room.startAudio();
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
export function useSessionMessages() {
|
|
136
|
+
useUpdates();
|
|
137
|
+
const session = useSessionContext();
|
|
138
|
+
const [sending, setSending] = useState(false);
|
|
139
|
+
const messages = useMemo(
|
|
140
|
+
() =>
|
|
141
|
+
window.__fixture.messages.map(({ isUser, ...message }) => ({
|
|
142
|
+
...message,
|
|
143
|
+
type: isUser ? 'userTranscript' : 'agentTranscript',
|
|
144
|
+
from: isUser ? session.room.localParticipant : undefined,
|
|
145
|
+
})) as ReceivedMessage[],
|
|
146
|
+
[window.__fixture.messages, session.room],
|
|
147
|
+
);
|
|
148
|
+
return {
|
|
149
|
+
messages,
|
|
150
|
+
isSending: sending,
|
|
151
|
+
send: async (message: string) => {
|
|
152
|
+
setSending(true);
|
|
153
|
+
try {
|
|
154
|
+
if (window.__fixture.failSend) throw new Error('send failed');
|
|
155
|
+
const id = String(window.__fixture.messages.length);
|
|
156
|
+
window.__fixture.messages = [
|
|
157
|
+
...window.__fixture.messages,
|
|
158
|
+
{ id: `user-${id}`, timestamp: Date.now(), message, isUser: true },
|
|
159
|
+
{
|
|
160
|
+
id: `agent-${id}`,
|
|
161
|
+
timestamp: Date.now(),
|
|
162
|
+
message: 'I hear you.',
|
|
163
|
+
isUser: false,
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
window.dispatchEvent(new Event('fixture:update'));
|
|
167
|
+
await Promise.resolve();
|
|
168
|
+
} finally {
|
|
169
|
+
setSending(false);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { TranscriptMessage } from '../../web/components/agents-ui/agent-chat-transcript.js';
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
interface Window {
|
|
5
|
+
__fixture: {
|
|
6
|
+
events: string[];
|
|
7
|
+
failMicrophone: boolean;
|
|
8
|
+
failSend: boolean;
|
|
9
|
+
stallConnect: boolean;
|
|
10
|
+
pendingAgent: boolean;
|
|
11
|
+
blockedAudio: boolean;
|
|
12
|
+
messages: TranscriptMessage[];
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
window.__fixture = {
|
|
17
|
+
events: [],
|
|
18
|
+
failMicrophone: false,
|
|
19
|
+
failSend: false,
|
|
20
|
+
stallConnect: false,
|
|
21
|
+
pendingAgent: false,
|
|
22
|
+
blockedAudio: false,
|
|
23
|
+
messages: [],
|
|
24
|
+
};
|
|
25
|
+
export const ConnectionState = {
|
|
26
|
+
Disconnected: 'disconnected',
|
|
27
|
+
Connected: 'connected',
|
|
28
|
+
Reconnecting: 'reconnecting',
|
|
29
|
+
SignalReconnecting: 'signalReconnecting',
|
|
30
|
+
Connecting: 'connecting',
|
|
31
|
+
} as const;
|
|
32
|
+
export const RoomEvent = { Disconnected: 'disconnected' } as const;
|
|
33
|
+
export const Track = { Source: { Microphone: 'microphone' } } as const;
|
|
34
|
+
export class Room {
|
|
35
|
+
private listeners = new Map<string, Set<() => void>>();
|
|
36
|
+
state: string = ConnectionState.Disconnected;
|
|
37
|
+
audioUnlocked = false;
|
|
38
|
+
activeDeviceId = 'default';
|
|
39
|
+
localParticipant = {
|
|
40
|
+
identity: 'local',
|
|
41
|
+
isLocal: true,
|
|
42
|
+
isMicrophoneEnabled: false,
|
|
43
|
+
getTrackPublication: () => ({
|
|
44
|
+
track: {
|
|
45
|
+
stop: () => {
|
|
46
|
+
this.localParticipant.isMicrophoneEnabled = false;
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
setMicrophoneEnabled: async (enabled: boolean) => {
|
|
51
|
+
window.__fixture.events.push(`microphone:${enabled}`);
|
|
52
|
+
if (enabled && window.__fixture.failMicrophone)
|
|
53
|
+
throw new DOMException('denied', 'NotAllowedError');
|
|
54
|
+
this.localParticipant.isMicrophoneEnabled = enabled;
|
|
55
|
+
this.update();
|
|
56
|
+
await Promise.resolve();
|
|
57
|
+
return {
|
|
58
|
+
track: {
|
|
59
|
+
stop: () => {
|
|
60
|
+
this.localParticipant.isMicrophoneEnabled = false;
|
|
61
|
+
window.__fixture.events.push('track:stop');
|
|
62
|
+
this.update();
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
constructor(options: { singlePeerConnection: boolean }) {
|
|
69
|
+
window.__fixture.events.push(`room:${options.singlePeerConnection}`);
|
|
70
|
+
window.addEventListener('fixture:disconnect', () => {
|
|
71
|
+
void this.disconnect();
|
|
72
|
+
});
|
|
73
|
+
window.addEventListener('fixture:reconnecting', () => {
|
|
74
|
+
this.state = ConnectionState.Reconnecting;
|
|
75
|
+
this.update();
|
|
76
|
+
});
|
|
77
|
+
window.addEventListener('fixture:reconnected', () => {
|
|
78
|
+
this.state = ConnectionState.Connected;
|
|
79
|
+
this.update();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
update() {
|
|
83
|
+
window.dispatchEvent(new Event('fixture:update'));
|
|
84
|
+
}
|
|
85
|
+
on(event: string, callback: () => void) {
|
|
86
|
+
const listeners = this.listeners.get(event) ?? new Set();
|
|
87
|
+
listeners.add(callback);
|
|
88
|
+
this.listeners.set(event, listeners);
|
|
89
|
+
return this;
|
|
90
|
+
}
|
|
91
|
+
off(event: string, callback: () => void) {
|
|
92
|
+
this.listeners.get(event)?.delete(callback);
|
|
93
|
+
return this;
|
|
94
|
+
}
|
|
95
|
+
prepareConnection() {
|
|
96
|
+
window.__fixture.events.push('prepare-connection');
|
|
97
|
+
return Promise.resolve();
|
|
98
|
+
}
|
|
99
|
+
async connect() {
|
|
100
|
+
window.__fixture.events.push('room:connect');
|
|
101
|
+
if (window.__fixture.stallConnect) await new Promise<void>(() => undefined);
|
|
102
|
+
this.state = ConnectionState.Connected;
|
|
103
|
+
this.update();
|
|
104
|
+
}
|
|
105
|
+
async disconnect() {
|
|
106
|
+
window.__fixture.events.push('room:disconnect');
|
|
107
|
+
this.localParticipant.isMicrophoneEnabled = false;
|
|
108
|
+
this.state = ConnectionState.Disconnected;
|
|
109
|
+
this.listeners.get('disconnected')?.forEach((callback) => callback());
|
|
110
|
+
this.update();
|
|
111
|
+
await Promise.resolve();
|
|
112
|
+
}
|
|
113
|
+
async startAudio() {
|
|
114
|
+
if (!window.__fixture.blockedAudio) this.audioUnlocked = true;
|
|
115
|
+
this.update();
|
|
116
|
+
await Promise.resolve();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export const TokenSource = {
|
|
120
|
+
literal: (credentials: { serverUrl: string; participantToken: string }) => ({
|
|
121
|
+
fetch: () => Promise.resolve(credentials),
|
|
122
|
+
}),
|
|
123
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Avatar conversation fixture</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/tests/e2e/harness.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '../../web/main.js';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { defineConfig } from 'vite';
|
|
3
|
+
import react from '@vitejs/plugin-react';
|
|
4
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
5
|
+
|
|
6
|
+
// This configuration is used only by Playwright. Production has no fixture mode.
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
plugins: [
|
|
9
|
+
react(),
|
|
10
|
+
tailwindcss(),
|
|
11
|
+
{
|
|
12
|
+
name: 'test-only-avatar',
|
|
13
|
+
enforce: 'pre',
|
|
14
|
+
configureServer(server) {
|
|
15
|
+
server.middlewares.use('/api/session', (request, response, next) => {
|
|
16
|
+
if (request.method !== 'POST') return next();
|
|
17
|
+
response.setHeader('Content-Type', 'application/json');
|
|
18
|
+
response.end(
|
|
19
|
+
JSON.stringify({
|
|
20
|
+
server_url: 'wss://example.invalid',
|
|
21
|
+
participant_token: 'fixture-token',
|
|
22
|
+
room_name: 'fixture-room',
|
|
23
|
+
spatius_app_id: 'fixture-app',
|
|
24
|
+
spatius_avatar_id: 'fixture-avatar',
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
resolveId(source, importer) {
|
|
30
|
+
if (
|
|
31
|
+
source === './avatar-session.js' &&
|
|
32
|
+
importer?.endsWith('/web/session-attempt.ts')
|
|
33
|
+
)
|
|
34
|
+
return resolve(import.meta.dirname, 'fake-avatar.ts');
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
resolve: {
|
|
39
|
+
alias: {
|
|
40
|
+
'@': resolve(import.meta.dirname, '../../web'),
|
|
41
|
+
'livekit-client': resolve(import.meta.dirname, 'fake-livekit.ts'),
|
|
42
|
+
'@livekit/components-react': resolve(
|
|
43
|
+
import.meta.dirname,
|
|
44
|
+
'fake-components.tsx',
|
|
45
|
+
),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { requestVoiceSession } from '../../web/api.js';
|
|
3
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
4
|
+
describe('session bootstrap', () => {
|
|
5
|
+
it.each([
|
|
6
|
+
'https://cdn.example.com/room.jpg',
|
|
7
|
+
'javascript:alert(1)',
|
|
8
|
+
'https://user:password@example.com/image',
|
|
9
|
+
42,
|
|
10
|
+
null,
|
|
11
|
+
])(
|
|
12
|
+
'accepts only safe optional public backgrounds: %s',
|
|
13
|
+
async (background) => {
|
|
14
|
+
vi.stubGlobal(
|
|
15
|
+
'fetch',
|
|
16
|
+
vi.fn().mockResolvedValue(
|
|
17
|
+
Response.json({
|
|
18
|
+
server_url: 'wss://example.invalid',
|
|
19
|
+
participant_token: 'token',
|
|
20
|
+
room_name: 'room',
|
|
21
|
+
spatius_app_id: 'app',
|
|
22
|
+
spatius_avatar_id: 'avatar',
|
|
23
|
+
spatius_avatar_background_url: background,
|
|
24
|
+
}),
|
|
25
|
+
),
|
|
26
|
+
);
|
|
27
|
+
const result = await requestVoiceSession();
|
|
28
|
+
expect(result.spatius_avatar_background_url).toBe(
|
|
29
|
+
background === 'https://cdn.example.com/room.jpg'
|
|
30
|
+
? background
|
|
31
|
+
: undefined,
|
|
32
|
+
);
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
it('POSTs with cancellation and no cache, validating the response', async () => {
|
|
37
|
+
const payload = {
|
|
38
|
+
server_url: 'wss://example.invalid',
|
|
39
|
+
participant_token: 'token',
|
|
40
|
+
room_name: 'room',
|
|
41
|
+
spatius_app_id: 'app',
|
|
42
|
+
spatius_avatar_id: 'avatar',
|
|
43
|
+
};
|
|
44
|
+
const fetch = vi.fn().mockResolvedValue(Response.json(payload));
|
|
45
|
+
vi.stubGlobal('fetch', fetch);
|
|
46
|
+
const signal = new AbortController().signal;
|
|
47
|
+
expect(await requestVoiceSession(signal)).toEqual(payload);
|
|
48
|
+
expect(fetch).toHaveBeenCalledExactlyOnceWith(
|
|
49
|
+
'/api/session',
|
|
50
|
+
expect.objectContaining({ method: 'POST', cache: 'no-store', signal }),
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
it('rejects malformed success and non-JSON failures', async () => {
|
|
54
|
+
vi.stubGlobal(
|
|
55
|
+
'fetch',
|
|
56
|
+
vi
|
|
57
|
+
.fn()
|
|
58
|
+
.mockResolvedValueOnce(Response.json({}))
|
|
59
|
+
.mockResolvedValueOnce(new Response('offline', { status: 503 })),
|
|
60
|
+
);
|
|
61
|
+
await expect(requestVoiceSession()).rejects.toThrow('invalid response');
|
|
62
|
+
await expect(requestVoiceSession()).rejects.toThrow('Could not start');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { StrictMode } from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
act,
|
|
6
|
+
cleanup,
|
|
7
|
+
fireEvent,
|
|
8
|
+
render,
|
|
9
|
+
screen,
|
|
10
|
+
} from '@testing-library/react';
|
|
11
|
+
const mocks = vi.hoisted(() => ({ create: vi.fn() }));
|
|
12
|
+
vi.mock('../../web/session-attempt.js', () => ({
|
|
13
|
+
SessionAttempt: class {
|
|
14
|
+
constructor() {
|
|
15
|
+
return mocks.create() as object;
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
vi.mock('../../web/components/conversation.js', () => ({
|
|
20
|
+
Conversation: () => <p>Live test conversation</p>,
|
|
21
|
+
}));
|
|
22
|
+
import App from '../../web/App.js';
|
|
23
|
+
function createAttempt() {
|
|
24
|
+
let resolve!: (value: object) => void;
|
|
25
|
+
let reject!: (error: Error) => void;
|
|
26
|
+
let progress!: (status: string) => void;
|
|
27
|
+
const abort = new AbortController();
|
|
28
|
+
return {
|
|
29
|
+
stage: 'bootstrap',
|
|
30
|
+
abort,
|
|
31
|
+
room: { startAudio: vi.fn().mockResolvedValue(undefined) },
|
|
32
|
+
prepare: vi.fn(
|
|
33
|
+
(_container: HTMLElement, report: (status: string) => void) => {
|
|
34
|
+
progress = report;
|
|
35
|
+
return new Promise<object>((done, fail) => {
|
|
36
|
+
resolve = done;
|
|
37
|
+
reject = fail;
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
),
|
|
41
|
+
dispose: vi.fn(() => {
|
|
42
|
+
abort.abort();
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
}),
|
|
45
|
+
resolve: (value: object) => resolve(value),
|
|
46
|
+
reject: (error: Error) => reject(error),
|
|
47
|
+
progress: (status: string) => progress(status),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
mocks.create.mockImplementation(createAttempt);
|
|
52
|
+
vi.stubGlobal('matchMedia', () => ({
|
|
53
|
+
matches: false,
|
|
54
|
+
addEventListener: vi.fn(),
|
|
55
|
+
removeEventListener: vi.fn(),
|
|
56
|
+
}));
|
|
57
|
+
});
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
cleanup();
|
|
60
|
+
vi.clearAllMocks();
|
|
61
|
+
vi.unstubAllGlobals();
|
|
62
|
+
});
|
|
63
|
+
describe('explicit app lifecycle', () => {
|
|
64
|
+
it('StrictMode mount does not create a room, fetch credentials or request microphone', () => {
|
|
65
|
+
render(
|
|
66
|
+
<StrictMode>
|
|
67
|
+
<App />
|
|
68
|
+
</StrictMode>,
|
|
69
|
+
);
|
|
70
|
+
expect(mocks.create).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
it('synchronously rejects double Start and ignores a stale progress/completion after cancel', async () => {
|
|
73
|
+
const attempt = createAttempt();
|
|
74
|
+
mocks.create.mockReturnValueOnce(attempt);
|
|
75
|
+
render(
|
|
76
|
+
<StrictMode>
|
|
77
|
+
<App />
|
|
78
|
+
</StrictMode>,
|
|
79
|
+
);
|
|
80
|
+
const start = screen.getByRole('button', { name: 'Start conversation' });
|
|
81
|
+
fireEvent.click(start);
|
|
82
|
+
fireEvent.click(start);
|
|
83
|
+
expect(mocks.create).toHaveBeenCalledOnce();
|
|
84
|
+
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
85
|
+
await act(async () => {
|
|
86
|
+
await Promise.resolve();
|
|
87
|
+
attempt.progress('STALE UPDATE');
|
|
88
|
+
attempt.resolve({ attempt, tokenSource: {} });
|
|
89
|
+
await Promise.resolve();
|
|
90
|
+
});
|
|
91
|
+
expect(screen.queryByText('STALE UPDATE')).toBeNull();
|
|
92
|
+
expect(screen.queryByText('Live test conversation')).toBeNull();
|
|
93
|
+
expect(
|
|
94
|
+
screen.getByRole('button', { name: 'Start conversation' }),
|
|
95
|
+
).toBeTruthy();
|
|
96
|
+
expect(attempt.abort.signal.aborted).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
it('disposes on unmount and never renders a late bootstrap result', async () => {
|
|
99
|
+
const attempt = createAttempt();
|
|
100
|
+
mocks.create.mockReturnValueOnce(attempt);
|
|
101
|
+
const { unmount } = render(<App />);
|
|
102
|
+
fireEvent.click(screen.getByRole('button', { name: 'Start conversation' }));
|
|
103
|
+
unmount();
|
|
104
|
+
await act(async () => {
|
|
105
|
+
attempt.resolve({ attempt, tokenSource: {} });
|
|
106
|
+
await Promise.resolve();
|
|
107
|
+
});
|
|
108
|
+
expect(attempt.dispose).toHaveBeenCalled();
|
|
109
|
+
expect(attempt.abort.signal.aborted).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
it('uses actionable bootstrap guidance without exposing raw error payloads', async () => {
|
|
112
|
+
const attempt = createAttempt();
|
|
113
|
+
mocks.create.mockReturnValueOnce(attempt);
|
|
114
|
+
render(<App />);
|
|
115
|
+
fireEvent.click(screen.getByRole('button', { name: 'Start conversation' }));
|
|
116
|
+
await act(async () => {
|
|
117
|
+
attempt.reject(new Error('secret-token=not-for-ui'));
|
|
118
|
+
await Promise.resolve();
|
|
119
|
+
});
|
|
120
|
+
expect(screen.getByRole('alert').textContent).toContain(
|
|
121
|
+
'Worker credentials',
|
|
122
|
+
);
|
|
123
|
+
expect(document.body.textContent).not.toContain('secret-token');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import type { LocalAudioTrack } from 'livekit-client';
|
|
5
|
+
import { useMultibandTrackVolume } from '@livekit/components-react';
|
|
6
|
+
import { AgentAudioVisualizerBar } from '../../web/components/agents-ui/agent-audio-visualizer-bar.js';
|
|
7
|
+
|
|
8
|
+
vi.mock('@livekit/components-react', () => ({
|
|
9
|
+
useMultibandTrackVolume: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
cleanup();
|
|
13
|
+
vi.resetAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('uses the local track and reacts to real bands, silence, mute and replacement tracks', () => {
|
|
17
|
+
const track = {} as LocalAudioTrack;
|
|
18
|
+
const volume = vi.mocked(useMultibandTrackVolume);
|
|
19
|
+
volume.mockReturnValue([0, 0.25, 0.5, 0.75, 1]);
|
|
20
|
+
const { container, rerender } = render(
|
|
21
|
+
<AgentAudioVisualizerBar audioTrack={track} enabled />,
|
|
22
|
+
);
|
|
23
|
+
const heights = () =>
|
|
24
|
+
[...container.querySelectorAll<HTMLElement>('.audio-bars span')].map(
|
|
25
|
+
(bar) => bar.style.height,
|
|
26
|
+
);
|
|
27
|
+
expect(
|
|
28
|
+
screen.getByRole('group', { name: 'Your microphone activity' }),
|
|
29
|
+
).toBeTruthy();
|
|
30
|
+
expect(volume).toHaveBeenLastCalledWith(track, {
|
|
31
|
+
bands: 5,
|
|
32
|
+
loPass: 100,
|
|
33
|
+
hiPass: 200,
|
|
34
|
+
});
|
|
35
|
+
expect(heights()).toEqual(['4px', '6px', '12px', '18px', '24px']);
|
|
36
|
+
volume.mockReturnValue([0, 0, 0, 0, 0]);
|
|
37
|
+
rerender(<AgentAudioVisualizerBar audioTrack={track} enabled />);
|
|
38
|
+
expect(heights()).toEqual(Array(5).fill('4px'));
|
|
39
|
+
volume.mockReturnValue([1, 1, 1, 1, 1]);
|
|
40
|
+
rerender(<AgentAudioVisualizerBar audioTrack={track} enabled={false} />);
|
|
41
|
+
expect(volume).toHaveBeenLastCalledWith(undefined, expect.anything());
|
|
42
|
+
expect(heights()).toEqual(Array(5).fill('4px'));
|
|
43
|
+
expect(screen.getByText('Microphone off')).toBeTruthy();
|
|
44
|
+
const replacement = {} as LocalAudioTrack;
|
|
45
|
+
rerender(<AgentAudioVisualizerBar audioTrack={replacement} enabled />);
|
|
46
|
+
expect(volume).toHaveBeenLastCalledWith(replacement, expect.anything());
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('renders five silent bars without a published microphone', () => {
|
|
50
|
+
vi.mocked(useMultibandTrackVolume).mockReturnValue([]);
|
|
51
|
+
const { container } = render(<AgentAudioVisualizerBar enabled={false} />);
|
|
52
|
+
expect(container.querySelectorAll('.audio-bars span')).toHaveLength(5);
|
|
53
|
+
});
|