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,327 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { delimiter, join } from 'node:path';
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import {
|
|
7
|
+
assertNoCredentials,
|
|
8
|
+
assertStopped,
|
|
9
|
+
credentials,
|
|
10
|
+
fixture,
|
|
11
|
+
readEvents,
|
|
12
|
+
waitForEvents,
|
|
13
|
+
windows,
|
|
14
|
+
} from './dev-fixtures.mjs';
|
|
15
|
+
|
|
16
|
+
test(
|
|
17
|
+
'compatible lk uses the agent cwd, file credentials, and explicit inherited overrides',
|
|
18
|
+
{ timeout: 15000 },
|
|
19
|
+
async (t) => {
|
|
20
|
+
const f = await fixture(t);
|
|
21
|
+
const overrides = {
|
|
22
|
+
LIVEKIT_API_KEY: 'inherited-api-key',
|
|
23
|
+
SPATIUS_APP_ID: 'inherited-app-id',
|
|
24
|
+
VIRTUAL_ENV: '/another/venv',
|
|
25
|
+
UV_PROJECT_ENVIRONMENT: '/another/venv',
|
|
26
|
+
};
|
|
27
|
+
const result = await f.start('dev-agent.mjs', overrides).done;
|
|
28
|
+
assert.equal(result.code, 0, result.output);
|
|
29
|
+
const events = await readEvents(f.events);
|
|
30
|
+
assert.deepEqual(
|
|
31
|
+
events.map(({ role, args }) => [role, args]),
|
|
32
|
+
[
|
|
33
|
+
['lk', ['--version']],
|
|
34
|
+
['lk', ['agent', 'dev', '--help']],
|
|
35
|
+
['lk', ['agent', 'dev', 'src/agent.py']],
|
|
36
|
+
],
|
|
37
|
+
);
|
|
38
|
+
for (const event of events) assert.equal(event.cwd, f.agent);
|
|
39
|
+
for (const event of events.slice(0, 2))
|
|
40
|
+
assert.ok(
|
|
41
|
+
!Object.keys(event.env).some((key) => /^(LIVEKIT_|SPATIUS_)/.test(key)),
|
|
42
|
+
);
|
|
43
|
+
const runtime = events.at(-1);
|
|
44
|
+
assert.equal(runtime.env.LIVEKIT_API_KEY, overrides.LIVEKIT_API_KEY);
|
|
45
|
+
assert.equal(
|
|
46
|
+
runtime.env.LIVEKIT_API_SECRET,
|
|
47
|
+
credentials.LIVEKIT_API_SECRET,
|
|
48
|
+
);
|
|
49
|
+
assert.equal(runtime.env.SPATIUS_APP_ID, overrides.SPATIUS_APP_ID);
|
|
50
|
+
assert.equal(runtime.env.VIRTUAL_ENV, join(f.agent, '.venv'));
|
|
51
|
+
assert.equal(runtime.env.PATH.split(delimiter)[0], f.virtualBin);
|
|
52
|
+
assert.equal(runtime.env.UV_PROJECT_ENVIRONMENT, join(f.agent, '.venv'));
|
|
53
|
+
assert.equal(runtime.env.UV_NO_SYNC, '1');
|
|
54
|
+
assert.equal(runtime.env.UV_PYTHON_DOWNLOADS, 'never');
|
|
55
|
+
assertNoCredentials(result.output);
|
|
56
|
+
assertNoCredentials(JSON.stringify(events.map(({ args }) => args)));
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
test(
|
|
61
|
+
'inherited credentials work without a local file and https URLs are accepted',
|
|
62
|
+
{ timeout: 10000 },
|
|
63
|
+
async (t) => {
|
|
64
|
+
const f = await fixture(t, { local: null });
|
|
65
|
+
const result = await f.start('dev-agent.mjs', {
|
|
66
|
+
...credentials,
|
|
67
|
+
LIVEKIT_URL: 'https://dev-test.livekit.cloud',
|
|
68
|
+
}).done;
|
|
69
|
+
assert.equal(result.code, 0, result.output);
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
for (const [name, local, overrides, expected] of [
|
|
74
|
+
['missing credentials', null, {}, /Missing agent development credentials/],
|
|
75
|
+
[
|
|
76
|
+
'empty override',
|
|
77
|
+
credentials,
|
|
78
|
+
{ LIVEKIT_API_SECRET: '' },
|
|
79
|
+
/LIVEKIT_API_SECRET/,
|
|
80
|
+
],
|
|
81
|
+
[
|
|
82
|
+
'example credentials',
|
|
83
|
+
{ ...credentials, SPATIUS_API_KEY: 'your-spatius-api-key' },
|
|
84
|
+
{},
|
|
85
|
+
/Replace example values/,
|
|
86
|
+
],
|
|
87
|
+
[
|
|
88
|
+
'invalid URL',
|
|
89
|
+
credentials,
|
|
90
|
+
{ LIVEKIT_URL: 'invalid-private-server-value' },
|
|
91
|
+
/LIVEKIT_URL must be/,
|
|
92
|
+
],
|
|
93
|
+
[
|
|
94
|
+
'insecure URL',
|
|
95
|
+
credentials,
|
|
96
|
+
{ LIVEKIT_URL: 'http://localhost:7880' },
|
|
97
|
+
/LIVEKIT_URL must be/,
|
|
98
|
+
],
|
|
99
|
+
[
|
|
100
|
+
'URL containing credentials',
|
|
101
|
+
credentials,
|
|
102
|
+
{ LIVEKIT_URL: 'https://private:secret@example.com' },
|
|
103
|
+
/LIVEKIT_URL must be/,
|
|
104
|
+
],
|
|
105
|
+
]) {
|
|
106
|
+
test(
|
|
107
|
+
`${name} fails before any probe or runtime with safe guidance`,
|
|
108
|
+
{ timeout: 10000 },
|
|
109
|
+
async (t) => {
|
|
110
|
+
const f = await fixture(t, { local });
|
|
111
|
+
const result = await f.start('dev-agent.mjs', overrides).done;
|
|
112
|
+
assert.equal(result.code, 1);
|
|
113
|
+
assert.match(result.output, expected);
|
|
114
|
+
assert.match(result.output, /README.md/);
|
|
115
|
+
assert.deepEqual(await readEvents(f.events), []);
|
|
116
|
+
assertNoCredentials(result.output);
|
|
117
|
+
for (const value of Object.values(overrides).filter(Boolean))
|
|
118
|
+
assert.ok(!result.output.includes(value));
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const manager of ['uv', 'pip']) {
|
|
124
|
+
test(
|
|
125
|
+
`${manager}: missing Python environment fails before lk without installing`,
|
|
126
|
+
{ timeout: 10000 },
|
|
127
|
+
async (t) => {
|
|
128
|
+
const f = await fixture(t, { uv: manager === 'uv', python: false });
|
|
129
|
+
const result = await f.start().done;
|
|
130
|
+
assert.equal(result.code, 1);
|
|
131
|
+
assert.match(result.output, /agent\/\.venv/);
|
|
132
|
+
assert.deepEqual(await readEvents(f.events), []);
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const [name, env] of [
|
|
138
|
+
['old version', { DEV_TEST_VERSION: 'lk version 2.18.1' }],
|
|
139
|
+
['prerelease version', { DEV_TEST_VERSION: 'lk version 2.18.2-beta.1' }],
|
|
140
|
+
['unknown version', { DEV_TEST_VERSION: 'private-probe-output' }],
|
|
141
|
+
['version failure', { DEV_TEST_VERSION_EXIT: '7' }],
|
|
142
|
+
['missing dev capability', { DEV_TEST_HELP: 'NAME: lk agent' }],
|
|
143
|
+
['help failure', { DEV_TEST_HELP_EXIT: '2' }],
|
|
144
|
+
['version timeout', { DEV_TEST_PROBE: 'version-hang' }],
|
|
145
|
+
['help timeout', { DEV_TEST_PROBE: 'help-hang' }],
|
|
146
|
+
['excessive probe output', { DEV_TEST_PROBE: 'flood' }],
|
|
147
|
+
]) {
|
|
148
|
+
test(
|
|
149
|
+
`${name} falls back once to selected uv without exposing probe output`,
|
|
150
|
+
{ timeout: 15000 },
|
|
151
|
+
async (t) => {
|
|
152
|
+
const f = await fixture(t, { uv: true });
|
|
153
|
+
const result = await f.start('dev-agent.mjs', env).done;
|
|
154
|
+
assert.equal(result.code, 0, result.output);
|
|
155
|
+
const events = await readEvents(f.events);
|
|
156
|
+
const runtimes = events.filter(({ role }) => role === 'uv');
|
|
157
|
+
assert.equal(runtimes.length, 1);
|
|
158
|
+
assert.deepEqual(runtimes[0].args, [
|
|
159
|
+
'run',
|
|
160
|
+
'--no-sync',
|
|
161
|
+
'--directory',
|
|
162
|
+
f.agent,
|
|
163
|
+
'--extra',
|
|
164
|
+
'dev',
|
|
165
|
+
'python',
|
|
166
|
+
'-m',
|
|
167
|
+
'livekit.agents',
|
|
168
|
+
'start',
|
|
169
|
+
'src/agent.py',
|
|
170
|
+
'--dev',
|
|
171
|
+
]);
|
|
172
|
+
assert.equal(runtimes[0].cwd, f.agent);
|
|
173
|
+
assert.equal(
|
|
174
|
+
runtimes[0].env.LIVEKIT_API_SECRET,
|
|
175
|
+
credentials.LIVEKIT_API_SECRET,
|
|
176
|
+
);
|
|
177
|
+
assert.equal(runtimes[0].env.UV_OFFLINE, '1');
|
|
178
|
+
assert.ok(!result.output.includes('private-probe-output'));
|
|
179
|
+
assertNoCredentials(result.output);
|
|
180
|
+
await assertStopped(events.map(({ pid }) => pid));
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
test(
|
|
186
|
+
'absent lk uses pip .venv python and propagates its exit without uv',
|
|
187
|
+
{
|
|
188
|
+
timeout: 10000,
|
|
189
|
+
skip:
|
|
190
|
+
windows &&
|
|
191
|
+
'POSIX fake Python executable; Windows uv and lk cmd wrappers are exercised separately.',
|
|
192
|
+
},
|
|
193
|
+
async (t) => {
|
|
194
|
+
const f = await fixture(t, { lk: false });
|
|
195
|
+
const result = await f.start('dev-agent.mjs', {
|
|
196
|
+
DEV_TEST_RUNTIME_EXIT: '23',
|
|
197
|
+
}).done;
|
|
198
|
+
assert.equal(result.code, 23, result.output);
|
|
199
|
+
const events = await readEvents(f.events);
|
|
200
|
+
assert.equal(events.length, 1);
|
|
201
|
+
assert.equal(events[0].role, 'python');
|
|
202
|
+
assert.deepEqual(events[0].args, [
|
|
203
|
+
'-m',
|
|
204
|
+
'livekit.agents',
|
|
205
|
+
'start',
|
|
206
|
+
'src/agent.py',
|
|
207
|
+
'--dev',
|
|
208
|
+
]);
|
|
209
|
+
assert.equal(events[0].cwd, f.agent);
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
test(
|
|
214
|
+
'uv selection never silently switches to pip when uv is missing',
|
|
215
|
+
{ timeout: 10000 },
|
|
216
|
+
async (t) => {
|
|
217
|
+
const f = await fixture(t, { uv: true, lk: false });
|
|
218
|
+
await rm(join(f.bin, windows ? 'uv.cmd' : 'uv'));
|
|
219
|
+
const result = await f.start().done;
|
|
220
|
+
assert.equal(result.code, 1);
|
|
221
|
+
assert.match(result.output, /Could not launch the selected agent runtime/);
|
|
222
|
+
assert.deepEqual(await readEvents(f.events), []);
|
|
223
|
+
assertNoCredentials(result.output);
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
test(
|
|
228
|
+
'a compatible lk runtime failure propagates without fallback',
|
|
229
|
+
{ timeout: 10000 },
|
|
230
|
+
async (t) => {
|
|
231
|
+
const f = await fixture(t, { uv: true });
|
|
232
|
+
const result = await f.start('dev-agent.mjs', {
|
|
233
|
+
DEV_TEST_RUNTIME_EXIT: '31',
|
|
234
|
+
}).done;
|
|
235
|
+
assert.equal(result.code, 31, result.output);
|
|
236
|
+
assert.ok((await readEvents(f.events)).every(({ role }) => role === 'lk'));
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
test(
|
|
241
|
+
'the general Python check launcher needs no credentials and never probes lk',
|
|
242
|
+
{ timeout: 10000 },
|
|
243
|
+
async (t) => {
|
|
244
|
+
const f = await fixture(t, { uv: true, local: null });
|
|
245
|
+
const result = await f.start(
|
|
246
|
+
'run-agent-python.mjs',
|
|
247
|
+
{ DEV_TEST_RUNTIME_EXIT: '19' },
|
|
248
|
+
['-m', 'unittest', 'discover', '-s', 'tests'],
|
|
249
|
+
).done;
|
|
250
|
+
assert.equal(result.code, 19, result.output);
|
|
251
|
+
const events = await readEvents(f.events);
|
|
252
|
+
assert.equal(events.length, 1);
|
|
253
|
+
assert.equal(events[0].role, 'uv');
|
|
254
|
+
assert.deepEqual(events[0].args.slice(-5), [
|
|
255
|
+
'-m',
|
|
256
|
+
'unittest',
|
|
257
|
+
'discover',
|
|
258
|
+
'-s',
|
|
259
|
+
'tests',
|
|
260
|
+
]);
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
265
|
+
test(
|
|
266
|
+
`${signal} during a bounded probe stops without launching a fallback`,
|
|
267
|
+
{
|
|
268
|
+
timeout: 10000,
|
|
269
|
+
skip:
|
|
270
|
+
windows &&
|
|
271
|
+
'Windows signals use forced taskkill; timeout tree cleanup is covered above.',
|
|
272
|
+
},
|
|
273
|
+
async (t) => {
|
|
274
|
+
const f = await fixture(t, { uv: true });
|
|
275
|
+
const run = f.start('dev-agent.mjs', { DEV_TEST_PROBE: 'version-hang' });
|
|
276
|
+
const events = await waitForEvents(f.events, (events) =>
|
|
277
|
+
events.some(({ type }) => type === 'ready'),
|
|
278
|
+
);
|
|
279
|
+
run.child.kill(signal);
|
|
280
|
+
const result = await run.done;
|
|
281
|
+
assert.equal(result.code, signal === 'SIGINT' ? 130 : 143, result.output);
|
|
282
|
+
assert.ok(
|
|
283
|
+
(await readEvents(f.events)).every(({ role }) => role === 'lk'),
|
|
284
|
+
);
|
|
285
|
+
await assertStopped(events.map(({ pid }) => pid));
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
for (const mode of ['signal', 'leader-exit', 'forced-signal']) {
|
|
291
|
+
test(
|
|
292
|
+
`${mode} cleans runtime grandchildren even in another process group`,
|
|
293
|
+
{
|
|
294
|
+
timeout: 15000,
|
|
295
|
+
skip:
|
|
296
|
+
windows &&
|
|
297
|
+
'POSIX process groups; Windows taskkill /T is covered by supervision tests.',
|
|
298
|
+
},
|
|
299
|
+
async (t) => {
|
|
300
|
+
const f = await fixture(t);
|
|
301
|
+
const run = f.start('dev-agent.mjs', {
|
|
302
|
+
DEV_TEST_RUNTIME_HOLD: '1',
|
|
303
|
+
DEV_TEST_GRANDCHILD: '1',
|
|
304
|
+
DEV_TEST_DETACHED: '1',
|
|
305
|
+
DEV_TEST_EXIT_WHEN_RELEASED: '1',
|
|
306
|
+
DEV_TEST_RUNTIME_EXIT: '17',
|
|
307
|
+
DEV_TEST_IGNORE_SIGNALS: mode === 'forced-signal' ? '1' : '0',
|
|
308
|
+
});
|
|
309
|
+
const events = await waitForEvents(f.events, (events) =>
|
|
310
|
+
events.some(
|
|
311
|
+
({ role, type }) => role === 'grandchild' && type === 'ready',
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
await delay(250);
|
|
315
|
+
if (mode === 'leader-exit')
|
|
316
|
+
await writeFile(f.env.DEV_TEST_RELEASE, 'exit');
|
|
317
|
+
else run.child.kill('SIGTERM');
|
|
318
|
+
const result = await run.done;
|
|
319
|
+
assert.equal(
|
|
320
|
+
result.code,
|
|
321
|
+
mode === 'leader-exit' ? 17 : 143,
|
|
322
|
+
result.output,
|
|
323
|
+
);
|
|
324
|
+
await assertStopped(events.map(({ pid }) => pid));
|
|
325
|
+
},
|
|
326
|
+
);
|
|
327
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import {
|
|
4
|
+
cp,
|
|
5
|
+
mkdir,
|
|
6
|
+
mkdtemp,
|
|
7
|
+
readFile,
|
|
8
|
+
realpath,
|
|
9
|
+
rm,
|
|
10
|
+
writeFile,
|
|
11
|
+
} from 'node:fs/promises';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { delimiter, dirname, join, resolve } from 'node:path';
|
|
14
|
+
import process from 'node:process';
|
|
15
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
16
|
+
|
|
17
|
+
export const templateDirectory = resolve(import.meta.dirname, '..');
|
|
18
|
+
export const windows = process.platform === 'win32';
|
|
19
|
+
export const credentials = {
|
|
20
|
+
LIVEKIT_URL: 'wss://dev-test.livekit.cloud',
|
|
21
|
+
LIVEKIT_API_KEY: 'dev-test-api-key',
|
|
22
|
+
LIVEKIT_API_SECRET: 'dev-test-secret # with spaces',
|
|
23
|
+
SPATIUS_API_KEY: 'dev-test-spatius-key',
|
|
24
|
+
SPATIUS_APP_ID: 'dev-test-app-id',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const fakeRuntime = `
|
|
28
|
+
import { appendFileSync, existsSync } from 'node:fs';
|
|
29
|
+
import { spawn } from 'node:child_process';
|
|
30
|
+
const role = process.argv[2];
|
|
31
|
+
const args = process.argv.slice(3);
|
|
32
|
+
const selected = Object.fromEntries(Object.entries(process.env).filter(([key]) => /^(LIVEKIT_|SPATIUS_|UV_|VIRTUAL_ENV$|PATH$)/i.test(key)));
|
|
33
|
+
const event = (type, extra = {}) => appendFileSync(process.env.DEV_TEST_EVENTS, JSON.stringify({ type, role, pid: process.pid, cwd: process.cwd(), args, env: selected, ...extra }) + '\\n');
|
|
34
|
+
event('invoked');
|
|
35
|
+
const hold = () => {
|
|
36
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => {
|
|
37
|
+
event('stopped', { signal });
|
|
38
|
+
if (process.env.DEV_TEST_IGNORE_SIGNALS !== '1') process.exit(0);
|
|
39
|
+
});
|
|
40
|
+
setInterval(() => {}, 1000);
|
|
41
|
+
};
|
|
42
|
+
const ready = () => { event('ready'); };
|
|
43
|
+
const spawnGrandchild = () => spawn(process.execPath, [process.argv[1], 'grandchild'], {
|
|
44
|
+
env: process.env, stdio: 'inherit',
|
|
45
|
+
detached: process.env.DEV_TEST_DETACHED === '1' && process.platform !== 'win32',
|
|
46
|
+
});
|
|
47
|
+
if (role === 'lk' && args[0] === '--version') {
|
|
48
|
+
if (process.env.DEV_TEST_PROBE === 'version-hang') {
|
|
49
|
+
if (process.env.DEV_TEST_PROBE_GRANDCHILD === '1') spawnGrandchild();
|
|
50
|
+
hold(); ready();
|
|
51
|
+
}
|
|
52
|
+
else if (process.env.DEV_TEST_PROBE === 'flood') process.stdout.write('private-probe-output'.repeat(10000));
|
|
53
|
+
else { console.log(process.env.DEV_TEST_VERSION ?? 'lk version 2.18.2'); process.exit(Number(process.env.DEV_TEST_VERSION_EXIT ?? 0)); }
|
|
54
|
+
} else if (role === 'lk' && args.includes('--help')) {
|
|
55
|
+
if (process.env.DEV_TEST_PROBE === 'help-hang') { hold(); ready(); }
|
|
56
|
+
else { console.log(process.env.DEV_TEST_HELP ?? 'NAME: lk agent dev\\nUSAGE: lk agent dev [entrypoint]'); process.exit(Number(process.env.DEV_TEST_HELP_EXIT ?? 0)); }
|
|
57
|
+
} else if (role === 'grandchild') {
|
|
58
|
+
hold(); ready();
|
|
59
|
+
} else {
|
|
60
|
+
if (process.env.DEV_TEST_GRANDCHILD === '1') {
|
|
61
|
+
spawnGrandchild();
|
|
62
|
+
}
|
|
63
|
+
if (role === 'web') {
|
|
64
|
+
hold(); ready();
|
|
65
|
+
if (process.env.DEV_TEST_WEB_EXIT) {
|
|
66
|
+
const poll = setInterval(() => {
|
|
67
|
+
if (existsSync(process.env.DEV_TEST_RELEASE)) {
|
|
68
|
+
clearInterval(poll);
|
|
69
|
+
process.exit(Number(process.env.DEV_TEST_WEB_EXIT));
|
|
70
|
+
}
|
|
71
|
+
}, 20);
|
|
72
|
+
}
|
|
73
|
+
} else if (process.env.DEV_TEST_RUNTIME_HOLD === '1') {
|
|
74
|
+
hold(); ready();
|
|
75
|
+
if (process.env.DEV_TEST_EXIT_WHEN_RELEASED === '1') {
|
|
76
|
+
setInterval(() => {
|
|
77
|
+
if (existsSync(process.env.DEV_TEST_RELEASE)) process.exit(Number(process.env.DEV_TEST_RUNTIME_EXIT ?? 0));
|
|
78
|
+
}, 20);
|
|
79
|
+
}
|
|
80
|
+
} else process.exit(Number(process.env.DEV_TEST_RUNTIME_EXIT ?? 0));
|
|
81
|
+
}
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
export async function fixture(
|
|
85
|
+
t,
|
|
86
|
+
{ uv = false, lk = true, local = credentials, python = true } = {},
|
|
87
|
+
) {
|
|
88
|
+
const root = await realpath(
|
|
89
|
+
await mkdtemp(join(tmpdir(), 'spatius dev test ')),
|
|
90
|
+
);
|
|
91
|
+
const scripts = join(root, 'scripts');
|
|
92
|
+
const agent = join(root, 'agent');
|
|
93
|
+
const bin = join(root, 'fake tools');
|
|
94
|
+
const virtualBin = join(agent, '.venv', windows ? 'Scripts' : 'bin');
|
|
95
|
+
const events = join(root, 'events.jsonl');
|
|
96
|
+
const runtime = join(root, 'fake-runtime.mjs');
|
|
97
|
+
await Promise.all([
|
|
98
|
+
mkdir(bin, { recursive: true }),
|
|
99
|
+
mkdir(virtualBin, { recursive: true }),
|
|
100
|
+
cp(join(templateDirectory, 'scripts'), scripts, { recursive: true }),
|
|
101
|
+
]);
|
|
102
|
+
await writeFile(runtime, fakeRuntime);
|
|
103
|
+
await writeFile(events, '');
|
|
104
|
+
if (local)
|
|
105
|
+
await writeFile(
|
|
106
|
+
join(agent, '.env.local'),
|
|
107
|
+
Object.entries(local)
|
|
108
|
+
.map(([key, value]) => `${key}='${value}'`)
|
|
109
|
+
.join('\n'),
|
|
110
|
+
);
|
|
111
|
+
if (uv) await writeFile(join(agent, 'uv.lock'), '# selected uv\n');
|
|
112
|
+
|
|
113
|
+
const executable = async (name, directory = bin, role = name) => {
|
|
114
|
+
const path = join(directory, windows ? `${name}.cmd` : name);
|
|
115
|
+
const body = windows
|
|
116
|
+
? `@echo off\r\n"${process.execPath}" "${runtime}" ${role} %*\r\n`
|
|
117
|
+
: `#!/bin/sh\nexec '${process.execPath.replaceAll("'", "'\\''")}' '${runtime.replaceAll("'", "'\\''")}' ${role} "$@"\n`;
|
|
118
|
+
await writeFile(path, body, { mode: 0o755 });
|
|
119
|
+
return path;
|
|
120
|
+
};
|
|
121
|
+
if (lk) await executable('lk');
|
|
122
|
+
if (uv) await executable('uv');
|
|
123
|
+
if (python) {
|
|
124
|
+
if (windows)
|
|
125
|
+
await writeFile(join(virtualBin, 'python.exe'), 'preflight-only stub');
|
|
126
|
+
else await executable('python', virtualBin);
|
|
127
|
+
}
|
|
128
|
+
const env = Object.fromEntries(
|
|
129
|
+
Object.entries(process.env).filter(
|
|
130
|
+
([key]) =>
|
|
131
|
+
!/^(LIVEKIT_|SPATIUS_|UV_|PYTHON|VIRTUAL_ENV$|PATH$|NODE_OPTIONS$)/i.test(
|
|
132
|
+
key,
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
env.PATH = [
|
|
137
|
+
bin,
|
|
138
|
+
dirname(process.execPath),
|
|
139
|
+
...(windows
|
|
140
|
+
? [join(process.env.SystemRoot ?? 'C:\\Windows', 'System32')]
|
|
141
|
+
: []),
|
|
142
|
+
].join(delimiter);
|
|
143
|
+
env.DEV_TEST_EVENTS = events;
|
|
144
|
+
env.DEV_TEST_RELEASE = join(root, 'release');
|
|
145
|
+
env.NO_COLOR = '1';
|
|
146
|
+
const children = [];
|
|
147
|
+
t.after(async () => {
|
|
148
|
+
for (const child of children) {
|
|
149
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
150
|
+
child.kill('SIGKILL');
|
|
151
|
+
}
|
|
152
|
+
for (const event of await readEvents(events)) {
|
|
153
|
+
if (event.pid && event.pid !== process.pid) {
|
|
154
|
+
try {
|
|
155
|
+
process.kill(event.pid, 'SIGKILL');
|
|
156
|
+
} catch {
|
|
157
|
+
/* Already exited. */
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
await rm(root, { recursive: true, force: true });
|
|
162
|
+
});
|
|
163
|
+
const start = (script = 'dev-agent.mjs', extraEnv = {}, args = []) => {
|
|
164
|
+
const child = spawn(process.execPath, [join(scripts, script), ...args], {
|
|
165
|
+
cwd: tmpdir(),
|
|
166
|
+
env: { ...env, ...extraEnv },
|
|
167
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
168
|
+
});
|
|
169
|
+
children.push(child);
|
|
170
|
+
let output = '';
|
|
171
|
+
child.stdout.on('data', (chunk) => {
|
|
172
|
+
output += chunk;
|
|
173
|
+
});
|
|
174
|
+
child.stderr.on('data', (chunk) => {
|
|
175
|
+
output += chunk;
|
|
176
|
+
});
|
|
177
|
+
const done = new Promise((resolve, reject) => {
|
|
178
|
+
child.once('error', reject);
|
|
179
|
+
child.once('close', (code, signal) => resolve({ code, signal, output }));
|
|
180
|
+
});
|
|
181
|
+
return { child, done };
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
root,
|
|
185
|
+
agent,
|
|
186
|
+
virtualBin,
|
|
187
|
+
bin,
|
|
188
|
+
events,
|
|
189
|
+
env,
|
|
190
|
+
runtime,
|
|
191
|
+
executable,
|
|
192
|
+
start,
|
|
193
|
+
children,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function readEvents(path) {
|
|
198
|
+
return (await readFile(path, 'utf8'))
|
|
199
|
+
.trim()
|
|
200
|
+
.split('\n')
|
|
201
|
+
.filter(Boolean)
|
|
202
|
+
.map((line) => JSON.parse(line));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function waitForEvents(path, predicate) {
|
|
206
|
+
const deadline = Date.now() + 8000;
|
|
207
|
+
while (Date.now() < deadline) {
|
|
208
|
+
const events = await readEvents(path);
|
|
209
|
+
if (predicate(events)) return events;
|
|
210
|
+
await delay(25);
|
|
211
|
+
}
|
|
212
|
+
assert.fail('Timed out waiting for fake processes to become ready.');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function assertStopped(pids) {
|
|
216
|
+
const deadline = Date.now() + 5000;
|
|
217
|
+
while (Date.now() < deadline) {
|
|
218
|
+
const alive = pids.filter((pid) => {
|
|
219
|
+
try {
|
|
220
|
+
process.kill(pid, 0);
|
|
221
|
+
return true;
|
|
222
|
+
} catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
if (!alive.length) return;
|
|
227
|
+
await delay(25);
|
|
228
|
+
}
|
|
229
|
+
assert.fail('A fake runtime or descendant survived teardown.');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function assertNoCredentials(output) {
|
|
233
|
+
for (const value of Object.values(credentials))
|
|
234
|
+
assert.ok(!output.includes(value), 'Credential value leaked into output.');
|
|
235
|
+
}
|