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.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/dist/cli.js +9046 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/templates.js +2856 -0
  6. package/dist/templates.js.map +1 -0
  7. package/package.json +79 -0
  8. package/schemas/result-v1.schema.json +129 -0
  9. package/schemas/result-v2.schema.json +146 -0
  10. package/templates/cloudflare-livekit/.editorconfig +15 -0
  11. package/templates/cloudflare-livekit/.prettierignore +6 -0
  12. package/templates/cloudflare-livekit/.prettierrc.json +4 -0
  13. package/templates/cloudflare-livekit/AGENTS.md +41 -0
  14. package/templates/cloudflare-livekit/README.md +37 -0
  15. package/templates/cloudflare-livekit/agent/Dockerfile.pip +31 -0
  16. package/templates/cloudflare-livekit/agent/Dockerfile.uv +29 -0
  17. package/templates/cloudflare-livekit/agent/README.md +84 -0
  18. package/templates/cloudflare-livekit/agent/dockerignore +11 -0
  19. package/templates/cloudflare-livekit/agent/env.example +8 -0
  20. package/templates/cloudflare-livekit/agent/pyproject.toml +21 -0
  21. package/templates/cloudflare-livekit/agent/python-version +1 -0
  22. package/templates/cloudflare-livekit/agent/src/__init__.py +1 -0
  23. package/templates/cloudflare-livekit/agent/src/agent.py +97 -0
  24. package/templates/cloudflare-livekit/agent/src/dispatch_metadata.py +64 -0
  25. package/templates/cloudflare-livekit/agent/tests/test_agent_startup.py +211 -0
  26. package/templates/cloudflare-livekit/agent/tests/test_dispatch_metadata.py +97 -0
  27. package/templates/cloudflare-livekit/agent/uv.lock +2272 -0
  28. package/templates/cloudflare-livekit/components.json +23 -0
  29. package/templates/cloudflare-livekit/contracts/agent-dispatch-metadata.schema.json +40 -0
  30. package/templates/cloudflare-livekit/dev.vars.example +12 -0
  31. package/templates/cloudflare-livekit/eslint.config.mjs +41 -0
  32. package/templates/cloudflare-livekit/gitignore +30 -0
  33. package/templates/cloudflare-livekit/index.html +17 -0
  34. package/templates/cloudflare-livekit/npmrc +4 -0
  35. package/templates/cloudflare-livekit/package-lock.json +6514 -0
  36. package/templates/cloudflare-livekit/package.json +66 -0
  37. package/templates/cloudflare-livekit/playwright.config.ts +29 -0
  38. package/templates/cloudflare-livekit/pnpm-lock.yaml +4012 -0
  39. package/templates/cloudflare-livekit/pnpm-workspace.yaml +8 -0
  40. package/templates/cloudflare-livekit/scripts/agent-runtime.mjs +129 -0
  41. package/templates/cloudflare-livekit/scripts/command.mjs +52 -0
  42. package/templates/cloudflare-livekit/scripts/dev-agent.mjs +77 -0
  43. package/templates/cloudflare-livekit/scripts/dev-web.mjs +18 -0
  44. package/templates/cloudflare-livekit/scripts/managed-process.mjs +144 -0
  45. package/templates/cloudflare-livekit/scripts/process-tree.mjs +111 -0
  46. package/templates/cloudflare-livekit/scripts/run-agent-python.mjs +25 -0
  47. package/templates/cloudflare-livekit/tests/dev-agent.test.mjs +327 -0
  48. package/templates/cloudflare-livekit/tests/dev-fixtures.mjs +235 -0
  49. package/templates/cloudflare-livekit/tests/dev-supervision.test.mjs +203 -0
  50. package/templates/cloudflare-livekit/tests/e2e/conversation.spec.ts +376 -0
  51. package/templates/cloudflare-livekit/tests/e2e/fake-avatar.ts +24 -0
  52. package/templates/cloudflare-livekit/tests/e2e/fake-components.tsx +173 -0
  53. package/templates/cloudflare-livekit/tests/e2e/fake-livekit.ts +123 -0
  54. package/templates/cloudflare-livekit/tests/e2e/harness.html +12 -0
  55. package/templates/cloudflare-livekit/tests/e2e/harness.tsx +1 -0
  56. package/templates/cloudflare-livekit/tests/e2e/vite.config.ts +48 -0
  57. package/templates/cloudflare-livekit/tests/web/api.test.ts +64 -0
  58. package/templates/cloudflare-livekit/tests/web/app.test.tsx +125 -0
  59. package/templates/cloudflare-livekit/tests/web/audio-visualizer.test.tsx +53 -0
  60. package/templates/cloudflare-livekit/tests/web/avatar-session.test.ts +119 -0
  61. package/templates/cloudflare-livekit/tests/web/components.test.tsx +152 -0
  62. package/templates/cloudflare-livekit/tests/web/conversation.test.tsx +76 -0
  63. package/templates/cloudflare-livekit/tests/web/session-attempt.test.ts +178 -0
  64. package/templates/cloudflare-livekit/tsconfig.json +9 -0
  65. package/templates/cloudflare-livekit/tsconfig.node.json +23 -0
  66. package/templates/cloudflare-livekit/tsconfig.web.json +26 -0
  67. package/templates/cloudflare-livekit/tsconfig.worker.json +22 -0
  68. package/templates/cloudflare-livekit/vite.config.ts +49 -0
  69. package/templates/cloudflare-livekit/vitest.config.ts +11 -0
  70. package/templates/cloudflare-livekit/web/App.tsx +176 -0
  71. package/templates/cloudflare-livekit/web/api.ts +69 -0
  72. package/templates/cloudflare-livekit/web/assets/FONT-PROVENANCE.md +8 -0
  73. package/templates/cloudflare-livekit/web/assets/OFL.txt +92 -0
  74. package/templates/cloudflare-livekit/web/assets/inter-latin.woff2 +0 -0
  75. package/templates/cloudflare-livekit/web/avatar-session.ts +67 -0
  76. package/templates/cloudflare-livekit/web/components/agents-ui/LICENSE +201 -0
  77. package/templates/cloudflare-livekit/web/components/agents-ui/PROVENANCE.md +32 -0
  78. package/templates/cloudflare-livekit/web/components/agents-ui/agent-audio-visualizer-bar.tsx +43 -0
  79. package/templates/cloudflare-livekit/web/components/agents-ui/agent-chat-input.tsx +78 -0
  80. package/templates/cloudflare-livekit/web/components/agents-ui/agent-chat-transcript.tsx +145 -0
  81. package/templates/cloudflare-livekit/web/components/agents-ui/agent-disconnect-button.tsx +27 -0
  82. package/templates/cloudflare-livekit/web/components/agents-ui/agent-session-provider.tsx +29 -0
  83. package/templates/cloudflare-livekit/web/components/agents-ui/agent-track-toggle.tsx +51 -0
  84. package/templates/cloudflare-livekit/web/components/agents-ui/start-audio-button.tsx +13 -0
  85. package/templates/cloudflare-livekit/web/components/conversation.tsx +352 -0
  86. package/templates/cloudflare-livekit/web/components/icons.tsx +39 -0
  87. package/templates/cloudflare-livekit/web/components/transcript-panel.tsx +12 -0
  88. package/templates/cloudflare-livekit/web/main.tsx +17 -0
  89. package/templates/cloudflare-livekit/web/session-attempt.ts +110 -0
  90. package/templates/cloudflare-livekit/web/styles.css +571 -0
  91. package/templates/cloudflare-livekit/worker/dispatch-metadata.ts +22 -0
  92. package/templates/cloudflare-livekit/worker/index.test.ts +152 -0
  93. package/templates/cloudflare-livekit/worker/index.ts +61 -0
  94. package/templates/cloudflare-livekit/worker/session.ts +103 -0
  95. package/templates/cloudflare-livekit/worker-configuration.d.ts +17184 -0
  96. package/templates/cloudflare-livekit/wrangler.jsonc +27 -0
@@ -0,0 +1,119 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { Room } from 'livekit-client';
3
+ const sdk = vi.hoisted(() => ({
4
+ initialize: vi.fn(),
5
+ load: vi.fn(),
6
+ view: vi.fn(),
7
+ attach: vi.fn(),
8
+ detach: vi.fn(),
9
+ dispose: vi.fn(),
10
+ }));
11
+ vi.mock('@spatius/avatarkit', () => ({
12
+ AvatarSDK: { configuration: {}, initialize: sdk.initialize },
13
+ AvatarManager: { shared: { load: sdk.load } },
14
+ DrivingServiceMode: { rtc: 'rtc' },
15
+ AvatarView: class {
16
+ constructor() {
17
+ sdk.view();
18
+ }
19
+ dispose = sdk.dispose;
20
+ },
21
+ }));
22
+ vi.mock('@spatius/avatarkit-rtc', () => ({
23
+ LiveKitProvider: class {},
24
+ AvatarPlayer: class {
25
+ attach = sdk.attach;
26
+ detach = sdk.detach;
27
+ },
28
+ }));
29
+ import { attachAvatarSession } from '../../web/avatar-session.js';
30
+ const credentials = {
31
+ server_url: 'wss://example.invalid',
32
+ participant_token: 'token',
33
+ room_name: 'room',
34
+ spatius_app_id: 'app',
35
+ spatius_avatar_id: 'configured-avatar',
36
+ };
37
+ beforeEach(() => {
38
+ sdk.load.mockResolvedValue({});
39
+ sdk.attach.mockResolvedValue(undefined);
40
+ sdk.detach.mockResolvedValue(undefined);
41
+ });
42
+ afterEach(() => vi.clearAllMocks());
43
+ describe('AvatarKit external room adapter', () => {
44
+ it('applies the public background behind the view and removes it on disposal', async () => {
45
+ const container = { style: { backgroundImage: '' } } as HTMLElement;
46
+ const controller = await attachAvatarSession(
47
+ container,
48
+ {
49
+ ...credentials,
50
+ spatius_avatar_background_url: 'https://cdn.example.com/room.jpg',
51
+ },
52
+ {} as Room,
53
+ new AbortController().signal,
54
+ vi.fn(),
55
+ );
56
+ expect(container.style.backgroundImage).toBe(
57
+ 'url("https://cdn.example.com/room.jpg")',
58
+ );
59
+ await controller.dispose();
60
+ expect(container.style.backgroundImage).toBe('');
61
+ });
62
+
63
+ it('attaches to the host room and only detaches/disposes its own resources', async () => {
64
+ const room = { connect: vi.fn(), disconnect: vi.fn() } as unknown as Room;
65
+ const controller = await attachAvatarSession(
66
+ { style: { backgroundImage: '' } } as HTMLElement,
67
+ credentials,
68
+ room,
69
+ new AbortController().signal,
70
+ vi.fn(),
71
+ );
72
+ expect(sdk.load).toHaveBeenCalledWith('configured-avatar');
73
+ expect(sdk.attach).toHaveBeenCalledExactlyOnceWith(room);
74
+ expect(room.connect).not.toHaveBeenCalled();
75
+ await controller.dispose();
76
+ await controller.dispose();
77
+ expect(sdk.detach).toHaveBeenCalledOnce();
78
+ expect(sdk.dispose).toHaveBeenCalledOnce();
79
+ expect(room.disconnect).not.toHaveBeenCalled();
80
+ });
81
+ it('cleans up an attach failure and still disposes the view when detach fails', async () => {
82
+ sdk.attach.mockRejectedValueOnce(new Error('attach failed'));
83
+ sdk.detach.mockRejectedValueOnce(new Error('already detached'));
84
+ await expect(
85
+ attachAvatarSession(
86
+ { style: { backgroundImage: '' } } as HTMLElement,
87
+ credentials,
88
+ {} as Room,
89
+ new AbortController().signal,
90
+ vi.fn(),
91
+ ),
92
+ ).rejects.toThrow('attach failed');
93
+ expect(sdk.dispose).toHaveBeenCalledOnce();
94
+ });
95
+ it('does not construct a late renderer after an aborted asset load', async () => {
96
+ let finish!: (value: object) => void;
97
+ sdk.load.mockReturnValueOnce(
98
+ new Promise((resolve) => {
99
+ finish = resolve;
100
+ }),
101
+ );
102
+ const controller = new AbortController();
103
+ const pending = attachAvatarSession(
104
+ { style: { backgroundImage: '' } } as HTMLElement,
105
+ credentials,
106
+ {} as Room,
107
+ controller.signal,
108
+ vi.fn(),
109
+ );
110
+ const rejected = expect(pending).rejects.toMatchObject({
111
+ name: 'AbortError',
112
+ });
113
+ controller.abort();
114
+ finish({});
115
+ await rejected;
116
+ expect(sdk.view).not.toHaveBeenCalled();
117
+ expect(sdk.attach).not.toHaveBeenCalled();
118
+ });
119
+ });
@@ -0,0 +1,152 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import type { ReceivedMessage } from '@livekit/components-react';
4
+ import {
5
+ cleanup,
6
+ fireEvent,
7
+ render,
8
+ screen,
9
+ waitFor,
10
+ } from '@testing-library/react';
11
+ import { AgentChatInput } from '../../web/components/agents-ui/agent-chat-input.js';
12
+ import {
13
+ AgentChatTranscript,
14
+ snapshotMessages,
15
+ } from '../../web/components/agents-ui/agent-chat-transcript.js';
16
+ import { AgentTrackToggle } from '../../web/components/agents-ui/agent-track-toggle.js';
17
+ afterEach(cleanup);
18
+ describe('upstream UI adaptations', () => {
19
+ it('keeps failed draft, submits once, and clears only on success', async () => {
20
+ const onSend = vi
21
+ .fn()
22
+ .mockRejectedValueOnce(new Error('send failed'))
23
+ .mockResolvedValue(undefined);
24
+ render(<AgentChatInput onSend={onSend} />);
25
+ const input = screen.getByRole('textbox');
26
+ fireEvent.change(input, { target: { value: ' hello ' } });
27
+ fireEvent.click(screen.getByRole('button', { name: 'Send message' }));
28
+ await screen.findByRole('alert');
29
+ expect((input as HTMLTextAreaElement).value).toBe(' hello ');
30
+ fireEvent.click(screen.getByRole('button', { name: 'Send message' }));
31
+ await waitFor(() => expect((input as HTMLTextAreaElement).value).toBe(''));
32
+ expect(onSend).toHaveBeenNthCalledWith(1, 'hello');
33
+ expect(onSend).toHaveBeenCalledTimes(2);
34
+ });
35
+ it('does not send during IME composition, Shift+Enter, or whitespace input', () => {
36
+ const send = vi.fn();
37
+ render(<AgentChatInput onSend={send} />);
38
+ const input = screen.getByRole('textbox');
39
+ fireEvent.change(input, { target: { value: '你好' } });
40
+ fireEvent.keyDown(input, { key: 'Enter', isComposing: true });
41
+ fireEvent.keyDown(input, { key: 'Enter', shiftKey: true });
42
+ expect(send).not.toHaveBeenCalled();
43
+ fireEvent.change(input, { target: { value: ' ' } });
44
+ expect(
45
+ screen
46
+ .getByRole('button', { name: 'Send message' })
47
+ .hasAttribute('disabled'),
48
+ ).toBe(true);
49
+ });
50
+ it('updates streaming content by stable ID and renders untrusted text as text', () => {
51
+ const messages = [
52
+ { id: 'same', timestamp: 0, message: 'A partial', isUser: false },
53
+ ];
54
+ const { rerender } = render(<AgentChatTranscript messages={messages} />);
55
+ rerender(
56
+ <AgentChatTranscript
57
+ messages={[
58
+ { ...messages[0], message: '<img src=x onerror=alert(1)> complete' },
59
+ ]}
60
+ />,
61
+ );
62
+ expect(screen.queryByText('A partial')).toBeNull();
63
+ expect(screen.getAllByRole('article')).toHaveLength(1);
64
+ expect(document.querySelector('img')).toBeNull();
65
+ });
66
+ it('preserves repeated utterances with different stream IDs', () => {
67
+ const messages = snapshotMessages([
68
+ { id: 'one', type: 'agentTranscript', timestamp: 0, message: 'Yes' },
69
+ { id: 'two', type: 'agentTranscript', timestamp: 1, message: 'Yes' },
70
+ ]);
71
+ expect(messages).toHaveLength(2);
72
+ });
73
+ it('distinguishes the user, voice agent, and avatar relay without displaying service chat', () => {
74
+ const from = (identity: string, attributes = {}, isLocal = false) =>
75
+ ({ identity, attributes, isLocal }) as NonNullable<
76
+ ReceivedMessage['from']
77
+ >;
78
+ const messages: ReceivedMessage[] = [
79
+ {
80
+ id: 'user',
81
+ type: 'userTranscript',
82
+ timestamp: 0,
83
+ message: 'Hello',
84
+ from: from('me', {}, true),
85
+ },
86
+ {
87
+ id: 'agent',
88
+ type: 'agentTranscript',
89
+ timestamp: 1,
90
+ message: 'Hi',
91
+ from: from('python'),
92
+ },
93
+ {
94
+ id: 'avatar',
95
+ type: 'agentTranscript',
96
+ timestamp: 2,
97
+ message: 'Spoken reply',
98
+ from: from('renderer', { 'lk.publish_on_behalf': 'python' }),
99
+ },
100
+ {
101
+ id: 'service',
102
+ type: 'chatMessage',
103
+ timestamp: 3,
104
+ message: 'Service event',
105
+ from: from('renderer'),
106
+ },
107
+ {
108
+ id: 'other',
109
+ type: 'agentTranscript',
110
+ timestamp: 4,
111
+ message: 'Unrelated',
112
+ from: from('other'),
113
+ },
114
+ ];
115
+ const participants = {
116
+ agentIdentity: 'python',
117
+ avatarIdentity: 'renderer',
118
+ };
119
+ const snapshot = snapshotMessages(messages, participants);
120
+ expect(snapshot.map(({ source }) => source)).toEqual([
121
+ 'user',
122
+ 'agent',
123
+ 'avatar',
124
+ ]);
125
+ expect(snapshot.map(({ senderIdentity }) => senderIdentity)).toEqual([
126
+ 'me',
127
+ 'python',
128
+ 'renderer',
129
+ ]);
130
+ const final = snapshotMessages(
131
+ [
132
+ {
133
+ id: 'avatar',
134
+ type: 'agentTranscript',
135
+ timestamp: 2,
136
+ message: 'Spoken',
137
+ },
138
+ ],
139
+ participants,
140
+ snapshot,
141
+ );
142
+ expect(final).toEqual([{ ...snapshot[2], message: 'Spoken' }]);
143
+ });
144
+ it('exposes a controlled accessible microphone toggle', () => {
145
+ const change = vi.fn();
146
+ render(<AgentTrackToggle pressed={false} onPressedChange={change} />);
147
+ const button = screen.getByRole('button', { name: 'Enable microphone' });
148
+ fireEvent.click(button);
149
+ expect(button.getAttribute('aria-pressed')).toBe('false');
150
+ expect(change).toHaveBeenCalledWith(true);
151
+ });
152
+ });
@@ -0,0 +1,76 @@
1
+ // @vitest-environment jsdom
2
+ import { StrictMode } from 'react';
3
+ import { afterEach, expect, it, vi } from 'vitest';
4
+ import { cleanup, render, screen, waitFor } from '@testing-library/react';
5
+ import { Room } from 'livekit-client';
6
+ import type { PreparedSession } from '../../web/session-attempt.js';
7
+ import { Conversation } from '../../web/components/conversation.js';
8
+
9
+ // Keep the real device hook: its observable resets state when onError changes.
10
+ vi.mock('@livekit/components-react', async (importOriginal) => ({
11
+ ...(await importOriginal<typeof import('@livekit/components-react')>()),
12
+ SessionProvider: ({ children }: { children: React.ReactNode }) => children,
13
+ RoomAudioRenderer: () => null,
14
+ useSessionContext: () => ({ end: vi.fn() }),
15
+ useSession: () => ({
16
+ start: () => new Promise(() => {}),
17
+ isConnected: false,
18
+ }),
19
+ useAgent: () => ({ internal: {}, state: 'connecting' }),
20
+ useSessionMessages: () => ({ messages: [], isSending: false }),
21
+ useLocalParticipant: () => ({ isMicrophoneEnabled: false }),
22
+ useMultibandTrackVolume: () => [],
23
+ useStartAudio: () => ({ mergedProps: {} }),
24
+ }));
25
+
26
+ afterEach(() => {
27
+ cleanup();
28
+ vi.restoreAllMocks();
29
+ vi.unstubAllGlobals();
30
+ });
31
+
32
+ it('keeps the real LiveKit device subscription stable across renders', async () => {
33
+ vi.stubGlobal('isSecureContext', true);
34
+ const enumerateDevices = vi
35
+ .fn()
36
+ .mockResolvedValue([
37
+ { kind: 'audioinput', deviceId: 'default', label: 'Test microphone' },
38
+ ]);
39
+ vi.stubGlobal('navigator', {
40
+ mediaDevices: {
41
+ enumerateDevices,
42
+ addEventListener: vi.fn(),
43
+ removeEventListener: vi.fn(),
44
+ },
45
+ });
46
+ const errors = vi.spyOn(console, 'error').mockImplementation((message) => {
47
+ // Fail promptly instead of letting an effect loop hang the test runner.
48
+ if (String(message).includes('Maximum update depth'))
49
+ throw new Error('Device selector entered a render loop');
50
+ });
51
+ const room = new Room();
52
+ const prepared = {
53
+ attempt: { room, abort: new AbortController() },
54
+ tokenSource: {},
55
+ } as PreparedSession;
56
+ const view = () => (
57
+ <StrictMode>
58
+ <Conversation prepared={prepared} onEnd={() => {}} />
59
+ </StrictMode>
60
+ );
61
+ const { rerender } = render(view());
62
+ await waitFor(() =>
63
+ expect(
64
+ screen.getByRole('option', { name: 'Test microphone' }),
65
+ ).toBeTruthy(),
66
+ );
67
+ const initialCalls = enumerateDevices.mock.calls.length;
68
+ rerender(view());
69
+ await waitFor(() =>
70
+ expect(
71
+ screen.getByRole('option', { name: 'Test microphone' }),
72
+ ).toBeTruthy(),
73
+ );
74
+ expect(enumerateDevices).toHaveBeenCalledTimes(initialCalls);
75
+ expect(errors).not.toHaveBeenCalled();
76
+ });
@@ -0,0 +1,178 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { Room } from 'livekit-client';
3
+ import {
4
+ SessionAttempt,
5
+ type AttemptDependencies,
6
+ } from '../../web/session-attempt.js';
7
+ import type { VoiceSession } from '../../web/api.js';
8
+
9
+ const credentials: VoiceSession = {
10
+ server_url: 'wss://example.invalid',
11
+ participant_token: 'one-attempt',
12
+ room_name: 'room-one',
13
+ spatius_app_id: 'app',
14
+ spatius_avatar_id: 'avatar',
15
+ };
16
+ function deferred<T>() {
17
+ let resolve!: (value: T) => void;
18
+ const promise = new Promise<T>((done) => {
19
+ resolve = done;
20
+ });
21
+ return { promise, resolve };
22
+ }
23
+ function setup(overrides: Partial<AttemptDependencies> = {}) {
24
+ const stop = vi.fn();
25
+ const microphone = vi.fn().mockResolvedValue({ track: { stop } });
26
+ const disconnect = vi.fn().mockResolvedValue(undefined);
27
+ const prepareConnection = vi.fn().mockResolvedValue(undefined);
28
+ const room = {
29
+ disconnect,
30
+ prepareConnection,
31
+ localParticipant: { setMicrophoneEnabled: microphone },
32
+ } as unknown as Room;
33
+ const dispose = vi.fn().mockResolvedValue(undefined);
34
+ const deps: AttemptDependencies = {
35
+ createRoom: () => room,
36
+ request: vi.fn().mockResolvedValue(credentials),
37
+ attach: vi.fn().mockResolvedValue({ dispose }),
38
+ ...overrides,
39
+ };
40
+ return {
41
+ attempt: new SessionAttempt(deps),
42
+ deps,
43
+ room,
44
+ dispose,
45
+ disconnect,
46
+ prepareConnection,
47
+ microphone,
48
+ stop,
49
+ };
50
+ }
51
+ const container = {} as HTMLElement;
52
+ describe('per-attempt lifecycle', () => {
53
+ it('has no bootstrap side effects until prepare, and literal fetch never repeats the POST', async () => {
54
+ const { attempt, deps, room, prepareConnection } = setup();
55
+ expect(deps.request).not.toHaveBeenCalled();
56
+ expect(prepareConnection).not.toHaveBeenCalled();
57
+ const prepared = await attempt.prepare(container, vi.fn());
58
+ expect(deps.attach).toHaveBeenCalledWith(
59
+ container,
60
+ credentials,
61
+ room,
62
+ attempt.abort.signal,
63
+ expect.any(Function),
64
+ );
65
+ await prepared.tokenSource.fetch();
66
+ await prepared.tokenSource.fetch();
67
+ expect(deps.request).toHaveBeenCalledTimes(1);
68
+ expect(prepareConnection).toHaveBeenCalledExactlyOnceWith(
69
+ credentials.server_url,
70
+ credentials.participant_token,
71
+ );
72
+ await attempt.dispose();
73
+ });
74
+ it('aborts the request and never attaches when a cancelled request resolves late', async () => {
75
+ const request = deferred<VoiceSession>();
76
+ const { attempt, deps, prepareConnection } = setup({
77
+ request: () => request.promise,
78
+ });
79
+ const pending = attempt.prepare(container, vi.fn());
80
+ const rejected = expect(pending).rejects.toMatchObject({
81
+ name: 'AbortError',
82
+ });
83
+ await attempt.dispose();
84
+ request.resolve(credentials);
85
+ await rejected;
86
+ expect(deps.attach).not.toHaveBeenCalled();
87
+ expect(prepareConnection).not.toHaveBeenCalled();
88
+ expect(attempt.abort.signal.aborted).toBe(true);
89
+ });
90
+ it('loads the avatar without waiting for connection warmup', async () => {
91
+ const { attempt, prepareConnection, deps } = setup();
92
+ const warmup = deferred<void>();
93
+ prepareConnection.mockReturnValue(warmup.promise);
94
+ await attempt.prepare(container, vi.fn());
95
+ expect(deps.attach).toHaveBeenCalledOnce();
96
+ await attempt.dispose();
97
+ // Late warmup completion cannot join the room or restart a cancelled attempt.
98
+ warmup.resolve();
99
+ expect(attempt.abort.signal.aborted).toBe(true);
100
+ });
101
+ it('continues startup if optional connection warmup fails', async () => {
102
+ const { attempt, prepareConnection, deps } = setup();
103
+ prepareConnection.mockRejectedValue(new Error('offline'));
104
+ await attempt.prepare(container, vi.fn());
105
+ expect(deps.attach).toHaveBeenCalledOnce();
106
+ await attempt.dispose();
107
+ });
108
+ it('cleans an avatar that becomes ready after cancellation and disposes the room once', async () => {
109
+ const attach = deferred<{ dispose: () => Promise<void> }>();
110
+ const { attempt, disconnect } = setup({ attach: () => attach.promise });
111
+ const pending = attempt.prepare(container, vi.fn());
112
+ const rejected = expect(pending).rejects.toMatchObject({
113
+ name: 'AbortError',
114
+ });
115
+ await Promise.resolve();
116
+ await attempt.dispose();
117
+ await attempt.dispose();
118
+ const dispose = vi.fn().mockResolvedValue(undefined);
119
+ attach.resolve({ dispose });
120
+ await rejected;
121
+ expect(dispose).toHaveBeenCalledOnce();
122
+ expect(disconnect).toHaveBeenCalledExactlyOnceWith(true);
123
+ });
124
+ it('continues room cleanup if avatar disposal rejects', async () => {
125
+ const { attempt, dispose, disconnect } = setup();
126
+ await attempt.prepare(container, vi.fn());
127
+ dispose.mockRejectedValueOnce(new Error('renderer already gone'));
128
+ await expect(attempt.dispose()).resolves.toBeUndefined();
129
+ expect(disconnect).toHaveBeenCalledExactlyOnceWith(true);
130
+ });
131
+ it('stops a microphone track returned after the user ended the call', async () => {
132
+ const { attempt, microphone, stop } = setup();
133
+ const publication = deferred<{ track: { stop: () => void } }>();
134
+ microphone.mockReturnValueOnce(publication.promise);
135
+ const pending = attempt.setMicrophone(true);
136
+ const rejected = expect(pending).rejects.toMatchObject({
137
+ name: 'AbortError',
138
+ });
139
+ await attempt.dispose();
140
+ publication.resolve({ track: { stop } });
141
+ await rejected;
142
+ expect(stop).toHaveBeenCalledOnce();
143
+ expect(microphone).toHaveBeenLastCalledWith(false);
144
+ });
145
+ it('uses fresh credentials and a fresh room for retry', async () => {
146
+ const first = setup();
147
+ await first.attempt.prepare(container, vi.fn());
148
+ await first.attempt.dispose();
149
+ const second = setup({
150
+ request: vi
151
+ .fn()
152
+ .mockResolvedValue({ ...credentials, participant_token: 'new-token' }),
153
+ });
154
+ const prepared = await second.attempt.prepare(container, vi.fn());
155
+ expect(second.room).not.toBe(first.room);
156
+ expect(await prepared.tokenSource.fetch()).toMatchObject({
157
+ participantToken: 'new-token',
158
+ });
159
+ await second.attempt.dispose();
160
+ });
161
+ it('stops the replacement microphone when device switching resolves after End', async () => {
162
+ const { attempt, room, disconnect } = setup();
163
+ const stop = vi.fn();
164
+ room.localParticipant.getTrackPublication = vi
165
+ .fn()
166
+ .mockReturnValue({ track: { stop } });
167
+ const switching = deferred<void>();
168
+ const pending = attempt.switchMicrophoneDevice(() => switching.promise);
169
+ const rejected = expect(pending).rejects.toMatchObject({
170
+ name: 'AbortError',
171
+ });
172
+ await attempt.dispose();
173
+ switching.resolve();
174
+ await rejected;
175
+ expect(stop).toHaveBeenCalledOnce();
176
+ expect(disconnect).toHaveBeenCalledTimes(2);
177
+ });
178
+ });
@@ -0,0 +1,9 @@
1
+ {
2
+ "compilerOptions": { "paths": { "@/*": ["./web/*"] } },
3
+ "files": [],
4
+ "references": [
5
+ { "path": "./tsconfig.web.json" },
6
+ { "path": "./tsconfig.worker.json" },
7
+ { "path": "./tsconfig.node.json" }
8
+ ]
9
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
5
+ "target": "ES2023",
6
+ "lib": ["ES2023"],
7
+ "module": "ESNext",
8
+ "moduleResolution": "Bundler",
9
+ "types": ["node"],
10
+ "skipLibCheck": true,
11
+ "allowImportingTsExtensions": true,
12
+ "verbatimModuleSyntax": true,
13
+ "moduleDetection": "force",
14
+ "noEmit": true,
15
+ "strict": true
16
+ },
17
+ "include": [
18
+ "vite.config.ts",
19
+ "vitest.config.ts",
20
+ "playwright.config.ts",
21
+ "tests/e2e/vite.config.ts"
22
+ ]
23
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "paths": { "@/*": ["./web/*"] },
4
+ "composite": true,
5
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.web.tsbuildinfo",
6
+ "target": "ES2023",
7
+ "useDefineForClassFields": true,
8
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
9
+ "module": "ESNext",
10
+ "types": ["vite/client"],
11
+ "skipLibCheck": true,
12
+ "moduleResolution": "Bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "moduleDetection": "force",
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "noFallthroughCasesInSwitch": true,
22
+ "noUncheckedSideEffectImports": true
23
+ },
24
+ "include": ["web", "tests/web", "tests/e2e/**/*.ts", "tests/e2e/**/*.tsx"],
25
+ "exclude": ["tests/e2e/vite.config.ts"]
26
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.worker.tsbuildinfo",
5
+ "target": "ES2023",
6
+ "lib": ["ES2023", "WebWorker"],
7
+ "module": "ESNext",
8
+ "moduleResolution": "Bundler",
9
+ "types": ["./worker-configuration.d.ts"],
10
+ "skipLibCheck": true,
11
+ "allowImportingTsExtensions": true,
12
+ "verbatimModuleSyntax": true,
13
+ "moduleDetection": "force",
14
+ "noEmit": true,
15
+ "strict": true,
16
+ "noUnusedLocals": true,
17
+ "noUnusedParameters": true,
18
+ "noFallthroughCasesInSwitch": true,
19
+ "noUncheckedSideEffectImports": true
20
+ },
21
+ "include": ["worker", "worker-configuration.d.ts"]
22
+ }
@@ -0,0 +1,49 @@
1
+ import { cloudflare } from '@cloudflare/vite-plugin';
2
+ import { avatarkitVitePlugin } from '@spatius/avatarkit/vite';
3
+ import react from '@vitejs/plugin-react';
4
+ import tailwindcss from '@tailwindcss/vite';
5
+ import { cpSync, existsSync, mkdirSync } from 'node:fs';
6
+ import { resolve } from 'node:path';
7
+ import { defineConfig } from 'vite';
8
+
9
+ const outputDirectory = resolve(import.meta.dirname, 'dist');
10
+ const avatarkit = avatarkitVitePlugin();
11
+ avatarkit.applyToEnvironment = (environment) => environment.name === 'client';
12
+
13
+ export default defineConfig({
14
+ resolve: { alias: { '@': resolve(import.meta.dirname, 'web') } },
15
+ build: {
16
+ // AvatarKit includes its renderer and WebAssembly glue in a deliberate,
17
+ // lazy-loaded chunk. It is expected to be larger than a typical UI chunk.
18
+ chunkSizeWarningLimit: 3200,
19
+ },
20
+ plugins: [
21
+ react(),
22
+ tailwindcss(),
23
+ {
24
+ name: 'prepare-avatarkit-output',
25
+ applyToEnvironment: (environment) => environment.name === 'client',
26
+ buildStart() {
27
+ mkdirSync(resolve(outputDirectory, 'assets'), { recursive: true });
28
+ },
29
+ },
30
+ avatarkit,
31
+ {
32
+ name: 'relocate-avatarkit-output',
33
+ applyToEnvironment: (environment) => environment.name === 'client',
34
+ closeBundle() {
35
+ const generatedAssets = resolve(outputDirectory, 'assets');
36
+ const clientAssets = resolve(outputDirectory, 'client/assets');
37
+ const generatedHeaders = resolve(outputDirectory, '_headers');
38
+
39
+ mkdirSync(clientAssets, { recursive: true });
40
+ cpSync(generatedAssets, clientAssets, { recursive: true });
41
+
42
+ if (existsSync(generatedHeaders)) {
43
+ cpSync(generatedHeaders, resolve(outputDirectory, 'client/_headers'));
44
+ }
45
+ },
46
+ },
47
+ cloudflare(),
48
+ ],
49
+ });
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import { resolve } from 'node:path';
3
+
4
+ export default defineConfig({
5
+ resolve: { alias: { '@': resolve(import.meta.dirname, 'web') } },
6
+ test: {
7
+ environment: 'node',
8
+ include: ['worker/**/*.test.ts', 'tests/web/**/*.test.{ts,tsx}'],
9
+ exclude: ['tests/e2e/**', 'node_modules/**'],
10
+ },
11
+ });