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,203 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { delimiter, dirname, join } from 'node:path';
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
9
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
10
|
+
import { commandSpec } from '../scripts/command.mjs';
|
|
11
|
+
import {
|
|
12
|
+
assertNoCredentials,
|
|
13
|
+
assertStopped,
|
|
14
|
+
fixture,
|
|
15
|
+
templateDirectory,
|
|
16
|
+
readEvents,
|
|
17
|
+
waitForEvents,
|
|
18
|
+
windows,
|
|
19
|
+
} from './dev-fixtures.mjs';
|
|
20
|
+
|
|
21
|
+
// Resolves in generated projects and in the root CI installation. A missing
|
|
22
|
+
// dependency fails this suite rather than silently skipping supervision tests.
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const concurrentlyPackage = require.resolve('concurrently/package.json');
|
|
25
|
+
const concurrentlyBin = join(
|
|
26
|
+
dirname(concurrentlyPackage),
|
|
27
|
+
require(concurrentlyPackage).bin.concurrently,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
async function supervise(f, extraEnv) {
|
|
31
|
+
const viteBin = join(f.root, 'node_modules', 'vite', 'bin');
|
|
32
|
+
await mkdir(viteBin, { recursive: true });
|
|
33
|
+
await writeFile(
|
|
34
|
+
join(viteBin, 'vite.js'),
|
|
35
|
+
`process.argv = [process.argv[0], ${JSON.stringify(f.runtime)}, 'web']; await import(${JSON.stringify(pathToFileURL(f.runtime).href)});`,
|
|
36
|
+
);
|
|
37
|
+
const sourcePackage = JSON.parse(
|
|
38
|
+
await readFile(join(templateDirectory, 'package.json'), 'utf8'),
|
|
39
|
+
);
|
|
40
|
+
// Exercise the production script composition with npm, available alongside
|
|
41
|
+
// Node in both generated projects and root CI. Only PM selection changes.
|
|
42
|
+
const scripts = Object.fromEntries(
|
|
43
|
+
['dev', 'dev:web', 'agent:dev'].map((name) => [
|
|
44
|
+
name,
|
|
45
|
+
sourcePackage.scripts[name].replaceAll('pnpm run ', 'npm run '),
|
|
46
|
+
]),
|
|
47
|
+
);
|
|
48
|
+
await writeFile(
|
|
49
|
+
join(f.root, 'package.json'),
|
|
50
|
+
JSON.stringify({
|
|
51
|
+
name: 'dev-supervision-fixture',
|
|
52
|
+
private: true,
|
|
53
|
+
type: 'module',
|
|
54
|
+
scripts,
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
const localBin = join(f.root, 'node_modules', '.bin');
|
|
58
|
+
await mkdir(localBin, { recursive: true });
|
|
59
|
+
await writeFile(
|
|
60
|
+
join(localBin, windows ? 'concurrently.cmd' : 'concurrently'),
|
|
61
|
+
windows
|
|
62
|
+
? `@echo off\r\n"${process.execPath}" "${concurrentlyBin}" %*\r\n`
|
|
63
|
+
: `#!/bin/sh\nexec '${process.execPath.replaceAll("'", "'\\''")}' '${concurrentlyBin.replaceAll("'", "'\\''")}' "$@"\n`,
|
|
64
|
+
{ mode: 0o755 },
|
|
65
|
+
);
|
|
66
|
+
await writeFile(
|
|
67
|
+
join(localBin, windows ? 'vite.cmd' : 'vite'),
|
|
68
|
+
windows
|
|
69
|
+
? `@echo off\r\n"${process.execPath}" "${join(viteBin, 'vite.js')}" %*\r\n`
|
|
70
|
+
: `#!/bin/sh\nexec '${process.execPath.replaceAll("'", "'\\''")}' '${join(viteBin, 'vite.js').replaceAll("'", "'\\''")}' "$@"\n`,
|
|
71
|
+
{ mode: 0o755 },
|
|
72
|
+
);
|
|
73
|
+
const env = { ...f.env, ...extraEnv };
|
|
74
|
+
env.PATH += `${delimiter}${process.env.PATH ?? process.env.Path ?? ''}`;
|
|
75
|
+
const spec = commandSpec('npm', ['run', 'dev'], env);
|
|
76
|
+
const child = spawn(spec.command, spec.arguments_, {
|
|
77
|
+
cwd: f.root,
|
|
78
|
+
env,
|
|
79
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
80
|
+
detached: !windows,
|
|
81
|
+
windowsVerbatimArguments: spec.windowsVerbatimArguments,
|
|
82
|
+
});
|
|
83
|
+
f.children.push(child);
|
|
84
|
+
let output = '';
|
|
85
|
+
child.stdout.on('data', (chunk) => {
|
|
86
|
+
output += chunk;
|
|
87
|
+
});
|
|
88
|
+
child.stderr.on('data', (chunk) => {
|
|
89
|
+
output += chunk;
|
|
90
|
+
});
|
|
91
|
+
const done = new Promise((resolve, reject) => {
|
|
92
|
+
child.once('error', reject);
|
|
93
|
+
child.once('close', (code, signal) => resolve({ code, signal, output }));
|
|
94
|
+
});
|
|
95
|
+
return { child, done };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const [name, side, code] of [
|
|
99
|
+
['web exits zero', 'web', '0'],
|
|
100
|
+
['web fails', 'web', '7'],
|
|
101
|
+
['agent exits zero', 'agent', '0'],
|
|
102
|
+
['agent fails', 'agent', '29'],
|
|
103
|
+
]) {
|
|
104
|
+
test(
|
|
105
|
+
`real concurrently: ${name} stops its sibling and all grandchildren`,
|
|
106
|
+
{ timeout: 15000 },
|
|
107
|
+
async (t) => {
|
|
108
|
+
const f = await fixture(t);
|
|
109
|
+
const run = await supervise(f, {
|
|
110
|
+
DEV_TEST_RUNTIME_HOLD: '1',
|
|
111
|
+
DEV_TEST_GRANDCHILD: '1',
|
|
112
|
+
...(side === 'web'
|
|
113
|
+
? { DEV_TEST_WEB_EXIT: code }
|
|
114
|
+
: { DEV_TEST_EXIT_WHEN_RELEASED: '1', DEV_TEST_RUNTIME_EXIT: code }),
|
|
115
|
+
});
|
|
116
|
+
const events = await waitForEvents(
|
|
117
|
+
f.events,
|
|
118
|
+
(events) =>
|
|
119
|
+
events.filter(
|
|
120
|
+
({ role, type }) => role === 'grandchild' && type === 'ready',
|
|
121
|
+
).length === 2,
|
|
122
|
+
);
|
|
123
|
+
await delay(250);
|
|
124
|
+
await writeFile(f.env.DEV_TEST_RELEASE, 'exit');
|
|
125
|
+
const result = await run.done;
|
|
126
|
+
assert.notEqual(result.code, null, result.output);
|
|
127
|
+
if (code !== '0') assert.notEqual(result.code, 0, result.output);
|
|
128
|
+
assert.match(result.output, /SIGTERM/);
|
|
129
|
+
assertNoCredentials(result.output);
|
|
130
|
+
await assertStopped(events.map(({ pid }) => pid));
|
|
131
|
+
assert.equal(
|
|
132
|
+
(await readEvents(f.events)).filter(
|
|
133
|
+
({ role, args, type }) =>
|
|
134
|
+
role === 'lk' &&
|
|
135
|
+
type === 'invoked' &&
|
|
136
|
+
args.at(-1) === 'src/agent.py',
|
|
137
|
+
).length,
|
|
138
|
+
1,
|
|
139
|
+
);
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
test(
|
|
145
|
+
'real concurrently: Ctrl+C stops both services and their grandchildren',
|
|
146
|
+
{
|
|
147
|
+
timeout: 15000,
|
|
148
|
+
skip:
|
|
149
|
+
windows &&
|
|
150
|
+
'Node cannot send console Ctrl+C events on Windows; taskkill tree cleanup is tested separately.',
|
|
151
|
+
},
|
|
152
|
+
async (t) => {
|
|
153
|
+
const f = await fixture(t);
|
|
154
|
+
const run = await supervise(f, {
|
|
155
|
+
DEV_TEST_RUNTIME_HOLD: '1',
|
|
156
|
+
DEV_TEST_GRANDCHILD: '1',
|
|
157
|
+
DEV_TEST_DETACHED: '1',
|
|
158
|
+
});
|
|
159
|
+
const events = await waitForEvents(
|
|
160
|
+
f.events,
|
|
161
|
+
(events) =>
|
|
162
|
+
events.filter(
|
|
163
|
+
({ role, type }) => role === 'grandchild' && type === 'ready',
|
|
164
|
+
).length === 2,
|
|
165
|
+
);
|
|
166
|
+
await delay(250);
|
|
167
|
+
// A terminal Ctrl+C targets the foreground group, including npm's shells.
|
|
168
|
+
process.kill(-run.child.pid, 'SIGINT');
|
|
169
|
+
const result = await run.done;
|
|
170
|
+
// npm can re-raise SIGINT (Linux) or return an exit code (macOS). Both
|
|
171
|
+
// represent a completed Ctrl+C; the essential contract is full cleanup.
|
|
172
|
+
assert.ok(
|
|
173
|
+
result.signal === 'SIGINT' || result.code !== null,
|
|
174
|
+
result.output,
|
|
175
|
+
);
|
|
176
|
+
assertNoCredentials(result.output);
|
|
177
|
+
await assertStopped(events.map(({ pid }) => pid));
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
test(
|
|
182
|
+
'a timed-out CLI probe reaps its descendants before Python fallback, including Windows cmd wrappers',
|
|
183
|
+
{ timeout: 15000 },
|
|
184
|
+
async (t) => {
|
|
185
|
+
const f = await fixture(t, { uv: true });
|
|
186
|
+
const run = f.start('dev-agent.mjs', {
|
|
187
|
+
DEV_TEST_PROBE: 'version-hang',
|
|
188
|
+
DEV_TEST_PROBE_GRANDCHILD: '1',
|
|
189
|
+
DEV_TEST_DETACHED: '1',
|
|
190
|
+
});
|
|
191
|
+
const events = await waitForEvents(f.events, (events) =>
|
|
192
|
+
events.some(
|
|
193
|
+
({ role, type }) => role === 'grandchild' && type === 'ready',
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
const result = await run.done;
|
|
197
|
+
assert.equal(result.code, 0, result.output);
|
|
198
|
+
await assertStopped(events.map(({ pid }) => pid));
|
|
199
|
+
const after = await readEvents(f.events);
|
|
200
|
+
assert.equal(after.filter(({ role }) => role === 'uv').length, 1);
|
|
201
|
+
assertNoCredentials(result.output);
|
|
202
|
+
},
|
|
203
|
+
);
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
test.beforeEach(async ({ page }) => {
|
|
4
|
+
await page.route('**/*', async (route) => {
|
|
5
|
+
const url = new URL(route.request().url());
|
|
6
|
+
if (url.hostname !== '127.0.0.1') {
|
|
7
|
+
await route.abort();
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
if (url.pathname === '/api/session') {
|
|
11
|
+
await route.fulfill({
|
|
12
|
+
json: {
|
|
13
|
+
server_url: 'wss://example.invalid',
|
|
14
|
+
participant_token: 'fixture-token',
|
|
15
|
+
room_name: 'fixture-room',
|
|
16
|
+
spatius_app_id: 'fixture-app',
|
|
17
|
+
spatius_avatar_id: 'fixture-avatar',
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
await route.continue();
|
|
23
|
+
});
|
|
24
|
+
await page.goto('/tests/e2e/harness.html');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('minimal workspace exposes microphone activity, readable devices, and transcript', async ({
|
|
28
|
+
page,
|
|
29
|
+
}, testInfo) => {
|
|
30
|
+
await expect(
|
|
31
|
+
page.getByRole('heading', { name: 'Voice assistant' }),
|
|
32
|
+
).toBeVisible();
|
|
33
|
+
const transcript = page.getByRole('complementary', { name: 'Transcript' });
|
|
34
|
+
await expect(transcript).toBeVisible();
|
|
35
|
+
await page.screenshot({
|
|
36
|
+
path: testInfo.outputPath('idle.png'),
|
|
37
|
+
fullPage: true,
|
|
38
|
+
});
|
|
39
|
+
await page
|
|
40
|
+
.getByRole('button', { name: 'Start conversation', exact: true })
|
|
41
|
+
.click();
|
|
42
|
+
await expect(
|
|
43
|
+
page.getByRole('button', { name: 'Mute microphone' }),
|
|
44
|
+
).toBeEnabled();
|
|
45
|
+
const meter = page.getByRole('group', { name: 'Your microphone activity' });
|
|
46
|
+
await expect(meter).toBeVisible();
|
|
47
|
+
await expect(meter.locator('.audio-bars')).toHaveAttribute(
|
|
48
|
+
'data-enabled',
|
|
49
|
+
'true',
|
|
50
|
+
);
|
|
51
|
+
await expect(page.getByLabel('Microphone device')).toBeVisible();
|
|
52
|
+
const avatarBox = await page.getByLabel('Avatar stage').boundingBox();
|
|
53
|
+
const transcriptBox = await transcript.boundingBox();
|
|
54
|
+
expect(avatarBox).not.toBeNull();
|
|
55
|
+
expect(transcriptBox).not.toBeNull();
|
|
56
|
+
if (testInfo.project.name === 'desktop') {
|
|
57
|
+
expect(transcriptBox!.x).toBeGreaterThanOrEqual(
|
|
58
|
+
avatarBox!.x + avatarBox!.width,
|
|
59
|
+
);
|
|
60
|
+
} else {
|
|
61
|
+
expect(transcriptBox!.y).toBeGreaterThanOrEqual(
|
|
62
|
+
avatarBox!.y + avatarBox!.height,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
await page.screenshot({
|
|
66
|
+
path: testInfo.outputPath('connected.png'),
|
|
67
|
+
fullPage: true,
|
|
68
|
+
});
|
|
69
|
+
await page.getByRole('button', { name: 'Mute microphone' }).click();
|
|
70
|
+
await expect(meter).toContainText('Microphone off');
|
|
71
|
+
await expect(meter.locator('.audio-bars')).toHaveAttribute(
|
|
72
|
+
'data-enabled',
|
|
73
|
+
'false',
|
|
74
|
+
);
|
|
75
|
+
await page.getByRole('textbox', { name: 'Message your agent' }).fill('Hello');
|
|
76
|
+
await page.getByRole('button', { name: 'Send message' }).click();
|
|
77
|
+
await expect(page.getByRole('log')).toContainText('I hear you.');
|
|
78
|
+
await page.screenshot({
|
|
79
|
+
path: testInfo.outputPath('transcript.png'),
|
|
80
|
+
fullPage: true,
|
|
81
|
+
});
|
|
82
|
+
expect(
|
|
83
|
+
await page.evaluate(
|
|
84
|
+
() => document.documentElement.scrollWidth > window.innerWidth,
|
|
85
|
+
),
|
|
86
|
+
).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('explicit Start, attach ordering, one audio renderer, typed chat and full post-call transcript', async ({
|
|
90
|
+
page,
|
|
91
|
+
}) => {
|
|
92
|
+
let posts = 0;
|
|
93
|
+
page.on('request', (request) => {
|
|
94
|
+
if (request.url().endsWith('/api/session')) posts++;
|
|
95
|
+
});
|
|
96
|
+
expect(await page.evaluate(() => window.__fixture.events)).toEqual([]);
|
|
97
|
+
await page
|
|
98
|
+
.getByRole('button', { name: 'Start conversation', exact: true })
|
|
99
|
+
.click();
|
|
100
|
+
await expect(
|
|
101
|
+
page.getByRole('button', { name: 'Mute microphone' }),
|
|
102
|
+
).toBeEnabled();
|
|
103
|
+
expect(posts).toBe(1);
|
|
104
|
+
const events = await page.evaluate(() => window.__fixture.events);
|
|
105
|
+
expect(events).toContain('room:false');
|
|
106
|
+
expect(events.indexOf('avatar:attach')).toBeLessThan(
|
|
107
|
+
events.indexOf('room:connect'),
|
|
108
|
+
);
|
|
109
|
+
expect(events.filter((event) => event === 'room:connect')).toHaveLength(1);
|
|
110
|
+
await expect(page.getByTestId('remote-audio-renderer')).toHaveCount(1);
|
|
111
|
+
await page
|
|
112
|
+
.getByRole('textbox', { name: 'Message your agent' })
|
|
113
|
+
.fill('Hello there');
|
|
114
|
+
await page.getByRole('button', { name: 'Send message' }).click();
|
|
115
|
+
await expect(page.getByRole('log')).toContainText('Hello there');
|
|
116
|
+
await expect(page.getByRole('log')).toContainText('I hear you.');
|
|
117
|
+
await expect(page.getByText('Hello there', { exact: true })).toHaveCount(1);
|
|
118
|
+
await expect(page.getByLabel('Live captions')).toHaveCount(0);
|
|
119
|
+
await page.getByRole('button', { name: 'End conversation' }).click();
|
|
120
|
+
await expect(
|
|
121
|
+
page.getByRole('heading', { name: 'Conversation ended' }),
|
|
122
|
+
).toBeVisible();
|
|
123
|
+
await expect(page.getByTestId('remote-audio-renderer')).toHaveCount(0);
|
|
124
|
+
await expect(page.getByRole('log')).toContainText('Hello there');
|
|
125
|
+
expect(posts).toBe(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('mic denial retains typed chat, device selection, and audio unlock', async ({
|
|
129
|
+
page,
|
|
130
|
+
}) => {
|
|
131
|
+
await page.evaluate(() => {
|
|
132
|
+
window.__fixture.failMicrophone = true;
|
|
133
|
+
window.__fixture.blockedAudio = true;
|
|
134
|
+
});
|
|
135
|
+
await page
|
|
136
|
+
.getByRole('button', { name: 'Start conversation', exact: true })
|
|
137
|
+
.click();
|
|
138
|
+
await expect(page.getByRole('alert')).toContainText('continue by typing');
|
|
139
|
+
await page.getByRole('button', { name: 'Enable sound' }).click();
|
|
140
|
+
await expect(page.getByRole('button', { name: 'Enable sound' })).toBeHidden();
|
|
141
|
+
await page.getByRole('textbox').fill('Text still works');
|
|
142
|
+
await page.getByRole('button', { name: 'Send message' }).click();
|
|
143
|
+
await expect(page.getByRole('log')).toContainText('Text still works');
|
|
144
|
+
await page.evaluate(() => {
|
|
145
|
+
window.__fixture.failMicrophone = false;
|
|
146
|
+
});
|
|
147
|
+
await page.getByRole('button', { name: 'Enable microphone' }).click();
|
|
148
|
+
await expect(
|
|
149
|
+
page.getByRole('button', { name: 'Mute microphone' }),
|
|
150
|
+
).toBeEnabled();
|
|
151
|
+
await page.getByLabel('Microphone device').selectOption('headset');
|
|
152
|
+
expect(await page.evaluate(() => window.__fixture.events)).toContain(
|
|
153
|
+
'device:headset',
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('streamed updates replace transcript by ID and the panel stays open with keyboard input', async ({
|
|
158
|
+
page,
|
|
159
|
+
}) => {
|
|
160
|
+
await page
|
|
161
|
+
.getByRole('button', { name: 'Start conversation', exact: true })
|
|
162
|
+
.click();
|
|
163
|
+
await expect(
|
|
164
|
+
page.getByRole('button', { name: 'Mute microphone' }),
|
|
165
|
+
).toBeEnabled();
|
|
166
|
+
await page.evaluate(() => {
|
|
167
|
+
window.__fixture.messages = [
|
|
168
|
+
{ id: 'speech', timestamp: 0, message: 'Partial', isUser: false },
|
|
169
|
+
];
|
|
170
|
+
window.dispatchEvent(new Event('fixture:update'));
|
|
171
|
+
});
|
|
172
|
+
await expect(page.getByRole('log')).toContainText('Partial');
|
|
173
|
+
await page.evaluate(() => {
|
|
174
|
+
window.__fixture.messages = [
|
|
175
|
+
{
|
|
176
|
+
id: 'speech',
|
|
177
|
+
timestamp: 0,
|
|
178
|
+
message: 'Completed speech',
|
|
179
|
+
isUser: false,
|
|
180
|
+
},
|
|
181
|
+
];
|
|
182
|
+
window.dispatchEvent(new Event('fixture:update'));
|
|
183
|
+
});
|
|
184
|
+
await expect(page.getByRole('log')).toContainText('Completed speech');
|
|
185
|
+
await expect(page.getByRole('article')).toHaveCount(1);
|
|
186
|
+
await page.getByRole('textbox').focus();
|
|
187
|
+
await page.keyboard.press('Escape');
|
|
188
|
+
await expect(
|
|
189
|
+
page.getByRole('complementary', { name: 'Transcript' }),
|
|
190
|
+
).toBeVisible();
|
|
191
|
+
await expect(page.getByRole('textbox')).toBeFocused();
|
|
192
|
+
expect(
|
|
193
|
+
await page.evaluate(
|
|
194
|
+
() => document.documentElement.scrollWidth > window.innerWidth,
|
|
195
|
+
),
|
|
196
|
+
).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('reconnecting keeps the attempt and the final message survives disconnection in the same turn', async ({
|
|
200
|
+
page,
|
|
201
|
+
}) => {
|
|
202
|
+
let posts = 0;
|
|
203
|
+
page.on('request', (request) => {
|
|
204
|
+
if (request.url().endsWith('/api/session')) posts++;
|
|
205
|
+
});
|
|
206
|
+
await page
|
|
207
|
+
.getByRole('button', { name: 'Start conversation', exact: true })
|
|
208
|
+
.click();
|
|
209
|
+
await expect(
|
|
210
|
+
page.getByRole('button', { name: 'Mute microphone' }),
|
|
211
|
+
).toBeEnabled();
|
|
212
|
+
await page.evaluate(() =>
|
|
213
|
+
window.dispatchEvent(new Event('fixture:reconnecting')),
|
|
214
|
+
);
|
|
215
|
+
await expect(page.getByRole('status')).toContainText('Reconnecting');
|
|
216
|
+
await expect(page.getByTestId('remote-audio-renderer')).toHaveCount(1);
|
|
217
|
+
expect(posts).toBe(1);
|
|
218
|
+
await page.evaluate(() =>
|
|
219
|
+
window.dispatchEvent(new Event('fixture:reconnected')),
|
|
220
|
+
);
|
|
221
|
+
await expect(
|
|
222
|
+
page.getByRole('button', { name: 'Mute microphone' }),
|
|
223
|
+
).toBeEnabled();
|
|
224
|
+
await page.evaluate(() => {
|
|
225
|
+
window.__fixture.messages = [
|
|
226
|
+
{
|
|
227
|
+
id: 'farewell',
|
|
228
|
+
timestamp: 0,
|
|
229
|
+
message: 'The final reply',
|
|
230
|
+
isUser: false,
|
|
231
|
+
},
|
|
232
|
+
];
|
|
233
|
+
window.dispatchEvent(new Event('fixture:update'));
|
|
234
|
+
window.dispatchEvent(new Event('fixture:disconnect'));
|
|
235
|
+
});
|
|
236
|
+
await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible();
|
|
237
|
+
await expect(page.getByRole('log')).toContainText('The final reply');
|
|
238
|
+
expect(posts).toBe(1);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('agent join timeout aborts the attempt and exposes an explicit retry', async ({
|
|
242
|
+
page,
|
|
243
|
+
}) => {
|
|
244
|
+
await page.clock.install();
|
|
245
|
+
await page.evaluate(() => {
|
|
246
|
+
window.__fixture.stallConnect = true;
|
|
247
|
+
});
|
|
248
|
+
await page
|
|
249
|
+
.getByRole('button', { name: 'Start conversation', exact: true })
|
|
250
|
+
.click();
|
|
251
|
+
await expect(
|
|
252
|
+
page.getByRole('heading', { name: 'Connecting to the room' }),
|
|
253
|
+
).toBeVisible();
|
|
254
|
+
await page.clock.fastForward(33_000);
|
|
255
|
+
await expect(page.getByRole('alert')).toContainText(
|
|
256
|
+
'agent did not join in time',
|
|
257
|
+
);
|
|
258
|
+
await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible();
|
|
259
|
+
expect(await page.evaluate(() => window.__fixture.events)).toContain(
|
|
260
|
+
'avatar:detach',
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('cancel a pending bootstrap and ignore its late response', async ({
|
|
265
|
+
page,
|
|
266
|
+
}) => {
|
|
267
|
+
let release!: () => void;
|
|
268
|
+
const gate = new Promise<void>((resolve) => {
|
|
269
|
+
release = resolve;
|
|
270
|
+
});
|
|
271
|
+
await page.route('**/api/session', async (route) => {
|
|
272
|
+
await gate;
|
|
273
|
+
await route
|
|
274
|
+
.fulfill({
|
|
275
|
+
json: {
|
|
276
|
+
server_url: 'wss://example.invalid',
|
|
277
|
+
participant_token: 'late',
|
|
278
|
+
room_name: 'late',
|
|
279
|
+
spatius_app_id: 'app',
|
|
280
|
+
spatius_avatar_id: 'avatar',
|
|
281
|
+
},
|
|
282
|
+
})
|
|
283
|
+
.catch(() => undefined);
|
|
284
|
+
});
|
|
285
|
+
await page
|
|
286
|
+
.getByRole('button', { name: 'Start conversation', exact: true })
|
|
287
|
+
.click();
|
|
288
|
+
await page.getByRole('button', { name: 'Cancel' }).click();
|
|
289
|
+
release();
|
|
290
|
+
await expect(
|
|
291
|
+
page.getByRole('button', { name: 'Start conversation', exact: true }),
|
|
292
|
+
).toBeVisible();
|
|
293
|
+
expect(await page.evaluate(() => window.__fixture.events)).not.toContain(
|
|
294
|
+
'room:connect',
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('no credentials refetch on end; explicit retry creates another attempt', async ({
|
|
299
|
+
page,
|
|
300
|
+
}) => {
|
|
301
|
+
let posts = 0;
|
|
302
|
+
page.on('request', (request) => {
|
|
303
|
+
if (request.url().endsWith('/api/session')) posts++;
|
|
304
|
+
});
|
|
305
|
+
await page
|
|
306
|
+
.getByRole('button', { name: 'Start conversation', exact: true })
|
|
307
|
+
.click();
|
|
308
|
+
await expect(
|
|
309
|
+
page.getByRole('button', { name: 'Mute microphone' }),
|
|
310
|
+
).toBeEnabled();
|
|
311
|
+
await page.evaluate(() =>
|
|
312
|
+
window.dispatchEvent(new Event('fixture:disconnect')),
|
|
313
|
+
);
|
|
314
|
+
await expect(page.getByRole('alert')).toContainText('connection ended');
|
|
315
|
+
expect(posts).toBe(1);
|
|
316
|
+
await page.getByRole('button', { name: 'Try again' }).click();
|
|
317
|
+
await expect(
|
|
318
|
+
page.getByRole('button', { name: 'Mute microphone' }),
|
|
319
|
+
).toBeEnabled();
|
|
320
|
+
expect(posts).toBe(2);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('room connection waits for agent readiness without publishing a microphone', async ({
|
|
324
|
+
page,
|
|
325
|
+
}, testInfo) => {
|
|
326
|
+
await page.evaluate(() => {
|
|
327
|
+
window.__fixture.pendingAgent = true;
|
|
328
|
+
});
|
|
329
|
+
await page
|
|
330
|
+
.getByRole('button', { name: 'Start conversation', exact: true })
|
|
331
|
+
.click();
|
|
332
|
+
await expect(
|
|
333
|
+
page.getByRole('heading', { name: 'Waiting for your agent' }),
|
|
334
|
+
).toBeVisible();
|
|
335
|
+
await expect(page.getByRole('textbox')).toBeDisabled();
|
|
336
|
+
expect(await page.evaluate(() => window.__fixture.events)).not.toContain(
|
|
337
|
+
'microphone:true',
|
|
338
|
+
);
|
|
339
|
+
await page.screenshot({
|
|
340
|
+
path: testInfo.outputPath('waiting.png'),
|
|
341
|
+
fullPage: true,
|
|
342
|
+
});
|
|
343
|
+
await page.evaluate(() => {
|
|
344
|
+
window.__fixture.pendingAgent = false;
|
|
345
|
+
window.dispatchEvent(new Event('fixture:update'));
|
|
346
|
+
});
|
|
347
|
+
await expect(
|
|
348
|
+
page.getByRole('button', { name: 'Mute microphone' }),
|
|
349
|
+
).toBeEnabled();
|
|
350
|
+
await expect(
|
|
351
|
+
page.getByRole('heading', { name: 'Waiting for your agent' }),
|
|
352
|
+
).toHaveCount(0);
|
|
353
|
+
await expect(page.getByRole('textbox')).toBeEnabled();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('an agent that never initializes times out even after the room connects', async ({
|
|
357
|
+
page,
|
|
358
|
+
}) => {
|
|
359
|
+
await page.clock.install();
|
|
360
|
+
await page.evaluate(() => {
|
|
361
|
+
window.__fixture.pendingAgent = true;
|
|
362
|
+
});
|
|
363
|
+
await page
|
|
364
|
+
.getByRole('button', { name: 'Start conversation', exact: true })
|
|
365
|
+
.click();
|
|
366
|
+
await expect(
|
|
367
|
+
page.getByRole('heading', { name: 'Waiting for your agent' }),
|
|
368
|
+
).toBeVisible();
|
|
369
|
+
await page.clock.fastForward(33_000);
|
|
370
|
+
await expect(page.getByRole('alert')).toContainText(
|
|
371
|
+
'agent did not join in time',
|
|
372
|
+
);
|
|
373
|
+
expect(await page.evaluate(() => window.__fixture.events)).toContain(
|
|
374
|
+
'avatar:detach',
|
|
375
|
+
);
|
|
376
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export async function attachAvatarSession(
|
|
2
|
+
container: HTMLElement,
|
|
3
|
+
_credentials: unknown,
|
|
4
|
+
_room: unknown,
|
|
5
|
+
signal: AbortSignal,
|
|
6
|
+
) {
|
|
7
|
+
signal.throwIfAborted();
|
|
8
|
+
window.__fixture.events.push('avatar:attach');
|
|
9
|
+
const figure = document.createElement('div');
|
|
10
|
+
figure.className = 'fixture-avatar';
|
|
11
|
+
figure.setAttribute('aria-label', 'Test avatar renderer');
|
|
12
|
+
// Neutral geometry exercises the real stage's layout without a persona asset.
|
|
13
|
+
figure.style.cssText =
|
|
14
|
+
'position:absolute;inset:14% 25% 0;border-radius:48% 48% 0 0;background:linear-gradient(160deg,#c9c9b9,#83988a);box-shadow:0 0 80px #354b3a30';
|
|
15
|
+
container.append(figure);
|
|
16
|
+
await Promise.resolve();
|
|
17
|
+
return {
|
|
18
|
+
dispose: async () => {
|
|
19
|
+
window.__fixture.events.push('avatar:detach');
|
|
20
|
+
figure.remove();
|
|
21
|
+
await Promise.resolve();
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|