screenci 0.0.9 → 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -58
- package/dist/Dockerfile +48 -0
- package/dist/cli.d.ts +21 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1346 -384
- package/dist/cli.js.map +1 -1
- package/dist/e2e/instrument.e2e.js +12 -0
- package/dist/e2e/instrument.e2e.js.map +1 -1
- package/dist/index.d.ts +9 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/playwright.config.d.ts +1 -1
- package/dist/src/asset.d.ts +27 -67
- package/dist/src/asset.d.ts.map +1 -1
- package/dist/src/asset.js +44 -45
- package/dist/src/asset.js.map +1 -1
- package/dist/src/caption.d.ts +164 -54
- package/dist/src/caption.d.ts.map +1 -1
- package/dist/src/caption.js +304 -131
- package/dist/src/caption.js.map +1 -1
- package/dist/src/events.d.ts +69 -27
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +64 -40
- package/dist/src/events.js.map +1 -1
- package/dist/src/instrument.d.ts.map +1 -1
- package/dist/src/instrument.js +142 -35
- package/dist/src/instrument.js.map +1 -1
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/logger.js +2 -1
- package/dist/src/logger.js.map +1 -1
- package/dist/src/recording.d.ts +4 -0
- package/dist/src/recording.d.ts.map +1 -0
- package/dist/src/recording.js +2 -0
- package/dist/src/recording.js.map +1 -0
- package/dist/src/recordingData.d.ts +145 -0
- package/dist/src/recordingData.d.ts.map +1 -0
- package/dist/src/recordingData.js +2 -0
- package/dist/src/recordingData.js.map +1 -0
- package/dist/src/types.d.ts +201 -71
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +31 -1
- package/dist/src/types.js.map +1 -1
- package/dist/src/video.d.ts.map +1 -1
- package/dist/src/video.js +29 -24
- package/dist/src/video.js.map +1 -1
- package/dist/src/voices.d.ts +344 -41
- package/dist/src/voices.d.ts.map +1 -1
- package/dist/src/voices.js +261 -30
- package/dist/src/voices.js.map +1 -1
- package/dist/test-fixtures/screenci.config.d.ts +5 -0
- package/dist/test-fixtures/screenci.config.d.ts.map +1 -0
- package/dist/test-fixtures/screenci.config.js +4 -0
- package/dist/test-fixtures/screenci.config.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +39 -3
- package/skills/playwright-cli/SKILL.md +348 -0
- package/skills/screenci/SKILL.md +55 -0
- package/skills/screenci/references/init.md +47 -0
- package/skills/screenci/references/record.md +41 -0
- package/dist/reporter.d.ts +0 -9
- package/dist/reporter.d.ts.map +0 -1
- package/dist/reporter.js +0 -49
- package/dist/reporter.js.map +0 -1
- package/dist/src/caption.test-d.d.ts +0 -2
- package/dist/src/caption.test-d.d.ts.map +0 -1
- package/dist/src/caption.test-d.js +0 -50
- package/dist/src/caption.test-d.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,14 +1,350 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
import { spawn, spawnSync } from 'child_process';
|
|
3
3
|
import { createReadStream } from 'fs';
|
|
4
|
-
import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync } from 'fs';
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, } from 'fs';
|
|
5
5
|
import { createHash } from 'crypto';
|
|
6
6
|
import { createServer } from 'http';
|
|
7
7
|
import { mkdir, readdir, readFile, stat, writeFile } from 'fs/promises';
|
|
8
8
|
import { dirname, relative as pathRelative, resolve } from 'path';
|
|
9
|
-
import { createInterface } from 'readline/promises';
|
|
10
9
|
import { fileURLToPath } from 'url';
|
|
10
|
+
import { Command, CommanderError } from 'commander';
|
|
11
|
+
import { input, confirm } from '@inquirer/prompts';
|
|
12
|
+
import ora from 'ora';
|
|
13
|
+
import pc from 'picocolors';
|
|
11
14
|
import { logger } from './src/logger.js';
|
|
15
|
+
function resolveRecordingFileCandidates(filePath, configDir) {
|
|
16
|
+
return [
|
|
17
|
+
filePath,
|
|
18
|
+
resolve(configDir, 'videos', filePath),
|
|
19
|
+
resolve(configDir, pathRelative('/app', filePath)),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
async function readRecordingFile(filePath, configDir) {
|
|
23
|
+
for (const candidate of resolveRecordingFileCandidates(filePath, configDir)) {
|
|
24
|
+
try {
|
|
25
|
+
return { buffer: await readFile(candidate), resolvedPath: candidate };
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// try next candidate
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function contentTypeForPath(filePath) {
|
|
34
|
+
const ext = filePath.split('.').pop()?.toLowerCase() ?? 'bin';
|
|
35
|
+
const contentTypeMap = {
|
|
36
|
+
png: 'image/png',
|
|
37
|
+
jpg: 'image/jpeg',
|
|
38
|
+
jpeg: 'image/jpeg',
|
|
39
|
+
gif: 'image/gif',
|
|
40
|
+
webp: 'image/webp',
|
|
41
|
+
mp4: 'video/mp4',
|
|
42
|
+
webm: 'video/webm',
|
|
43
|
+
mp3: 'audio/mpeg',
|
|
44
|
+
svg: 'image/svg+xml',
|
|
45
|
+
};
|
|
46
|
+
return contentTypeMap[ext] ?? 'application/octet-stream';
|
|
47
|
+
}
|
|
48
|
+
class UploadCancelledError extends Error {
|
|
49
|
+
constructor(message = 'Upload cancelled') {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = 'UploadCancelledError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function isUploadCancelledError(err) {
|
|
55
|
+
return (err instanceof UploadCancelledError ||
|
|
56
|
+
(err instanceof Error &&
|
|
57
|
+
(err.name === 'AbortError' || err.name === 'UploadCancelledError')));
|
|
58
|
+
}
|
|
59
|
+
function createUploadAbortController(activityLabel) {
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
let cleanedUp = false;
|
|
62
|
+
const cleanup = () => {
|
|
63
|
+
if (cleanedUp)
|
|
64
|
+
return;
|
|
65
|
+
cleanedUp = true;
|
|
66
|
+
process.off('SIGINT', handleSigint);
|
|
67
|
+
process.off('SIGTERM', handleSigterm);
|
|
68
|
+
process.stdin.off('data', handleStdinData);
|
|
69
|
+
};
|
|
70
|
+
const abortUpload = (signal) => {
|
|
71
|
+
if (controller.signal.aborted)
|
|
72
|
+
return;
|
|
73
|
+
logger.info(`Received ${signal}, stopping ${activityLabel}...`);
|
|
74
|
+
cleanup();
|
|
75
|
+
controller.abort(new UploadCancelledError(`${activityLabel} cancelled`));
|
|
76
|
+
process.kill(process.pid, signal);
|
|
77
|
+
};
|
|
78
|
+
const handleStdinData = (chunk) => {
|
|
79
|
+
const bytes = typeof chunk === 'string'
|
|
80
|
+
? Buffer.from(chunk, 'utf8')
|
|
81
|
+
: Buffer.from(chunk);
|
|
82
|
+
if (bytes.includes(0x03)) {
|
|
83
|
+
abortUpload('SIGINT');
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const handleSigint = () => abortUpload('SIGINT');
|
|
87
|
+
const handleSigterm = () => abortUpload('SIGTERM');
|
|
88
|
+
process.on('SIGINT', handleSigint);
|
|
89
|
+
process.on('SIGTERM', handleSigterm);
|
|
90
|
+
process.stdin.on('data', handleStdinData);
|
|
91
|
+
return {
|
|
92
|
+
signal: controller.signal,
|
|
93
|
+
throwIfAborted: () => {
|
|
94
|
+
if (controller.signal.aborted) {
|
|
95
|
+
throw controller.signal.reason instanceof Error
|
|
96
|
+
? controller.signal.reason
|
|
97
|
+
: new UploadCancelledError(`${activityLabel} cancelled`);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
cleanup,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function parseDockerfileVersion(dockerfilePath) {
|
|
104
|
+
let content;
|
|
105
|
+
try {
|
|
106
|
+
content = readFileSync(dockerfilePath, 'utf-8');
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return 'unknown';
|
|
110
|
+
}
|
|
111
|
+
const fromLine = content
|
|
112
|
+
.split('\n')
|
|
113
|
+
.find((line) => line.trim().toUpperCase().startsWith('FROM'));
|
|
114
|
+
if (!fromLine)
|
|
115
|
+
return 'unknown';
|
|
116
|
+
const match = fromLine.match(/:([^\s@]+)/);
|
|
117
|
+
return match?.[1] ?? 'unknown';
|
|
118
|
+
}
|
|
119
|
+
const CONTAINER_RUNTIME_DOCS_URL = 'https://screenci.com/guides/getting-started/#prerequisites';
|
|
120
|
+
function prerequisitesMessage() {
|
|
121
|
+
return `See prerequisites: ${pc.blue(CONTAINER_RUNTIME_DOCS_URL)}`;
|
|
122
|
+
}
|
|
123
|
+
const MIN_CONTAINER_RUNTIME_MAJOR_VERSION = {
|
|
124
|
+
podman: 5,
|
|
125
|
+
docker: 28,
|
|
126
|
+
};
|
|
127
|
+
function parseContainerRuntimeMajorVersion(versionOutput) {
|
|
128
|
+
const match = versionOutput.match(/(\d+)(?:\.\d+){0,2}/);
|
|
129
|
+
if (!match)
|
|
130
|
+
return null;
|
|
131
|
+
const majorVersion = Number.parseInt(match[1] ?? '', 10);
|
|
132
|
+
return Number.isNaN(majorVersion) ? null : majorVersion;
|
|
133
|
+
}
|
|
134
|
+
function checkContainerRuntime(runtime) {
|
|
135
|
+
const result = spawnSync(runtime, ['--version'], { encoding: 'utf8' });
|
|
136
|
+
if (result.status !== 0 || result.error !== undefined) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const version = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
|
|
140
|
+
return {
|
|
141
|
+
runtime,
|
|
142
|
+
version,
|
|
143
|
+
majorVersion: parseContainerRuntimeMajorVersion(version),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function getPreferredContainerRuntime() {
|
|
147
|
+
const podman = checkContainerRuntime('podman');
|
|
148
|
+
if (podman)
|
|
149
|
+
return podman;
|
|
150
|
+
return checkContainerRuntime('docker');
|
|
151
|
+
}
|
|
152
|
+
function exitContainerRuntimeNotFound(runtime) {
|
|
153
|
+
logger.error(`Error: ${runtime} not found.`);
|
|
154
|
+
logger.error(`Install ${runtime} or remove the --${runtime} flag.`);
|
|
155
|
+
logger.error(prerequisitesMessage());
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
function warnIfContainerRuntimeVersionIsOld(runtimeCheck) {
|
|
159
|
+
const minimumVersion = MIN_CONTAINER_RUNTIME_MAJOR_VERSION[runtimeCheck.runtime];
|
|
160
|
+
if (runtimeCheck.majorVersion !== null &&
|
|
161
|
+
runtimeCheck.majorVersion < minimumVersion) {
|
|
162
|
+
logger.warn(`${runtimeCheck.runtime} ${minimumVersion}+ recommended (detected: ${runtimeCheck.version}). Most likely this still works, continuing.`);
|
|
163
|
+
logger.warn(prerequisitesMessage());
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function spawnSilent(cmd, args, cwd) {
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
const child = spawn(cmd, args, { stdio: 'pipe', ...(cwd ? { cwd } : {}) });
|
|
169
|
+
const childSignals = forwardChildSignals(child, cmd);
|
|
170
|
+
child.on('close', (code, signal) => {
|
|
171
|
+
const forwardedSignal = childSignals.getForwardedSignal();
|
|
172
|
+
childSignals.cleanup();
|
|
173
|
+
if (forwardedSignal) {
|
|
174
|
+
process.kill(process.pid, forwardedSignal);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (signal) {
|
|
178
|
+
process.kill(process.pid, signal);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
else if (code === 0) {
|
|
182
|
+
resolve();
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
reject(new Error(`${cmd} exited with code ${code}`));
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
child.on('error', (err) => {
|
|
189
|
+
childSignals.cleanup();
|
|
190
|
+
reject(err);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
function spawnCaptured(cmd, args, cwd) {
|
|
195
|
+
return new Promise((resolve, reject) => {
|
|
196
|
+
const child = spawn(cmd, args, { stdio: 'pipe', ...(cwd ? { cwd } : {}) });
|
|
197
|
+
const childSignals = forwardChildSignals(child, cmd);
|
|
198
|
+
let stdout = '';
|
|
199
|
+
let stderr = '';
|
|
200
|
+
child.stdout?.on('data', (chunk) => {
|
|
201
|
+
stdout += chunk.toString();
|
|
202
|
+
});
|
|
203
|
+
child.stderr?.on('data', (chunk) => {
|
|
204
|
+
stderr += chunk.toString();
|
|
205
|
+
});
|
|
206
|
+
child.on('close', (code, signal) => {
|
|
207
|
+
const forwardedSignal = childSignals.getForwardedSignal();
|
|
208
|
+
childSignals.cleanup();
|
|
209
|
+
if (forwardedSignal) {
|
|
210
|
+
process.kill(process.pid, forwardedSignal);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (signal) {
|
|
214
|
+
process.kill(process.pid, signal);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
resolve({ code, stdout, stderr });
|
|
218
|
+
});
|
|
219
|
+
child.on('error', (err) => {
|
|
220
|
+
childSignals.cleanup();
|
|
221
|
+
reject(err);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
function forwardChildSignals(child, activityLabel) {
|
|
226
|
+
let forwardedSignal = null;
|
|
227
|
+
let forceKillTimer = null;
|
|
228
|
+
const forwardSignal = (signal) => {
|
|
229
|
+
if (forwardedSignal !== null)
|
|
230
|
+
return;
|
|
231
|
+
forwardedSignal = signal;
|
|
232
|
+
if (process.env.SCREENCI_SIGNAL_LOGGING !== 'silent') {
|
|
233
|
+
logger.info(`Received ${signal}, stopping ${activityLabel}...`);
|
|
234
|
+
}
|
|
235
|
+
if (!child.killed) {
|
|
236
|
+
child.kill(signal);
|
|
237
|
+
}
|
|
238
|
+
forceKillTimer = setTimeout(() => {
|
|
239
|
+
if (child.exitCode === null) {
|
|
240
|
+
if (process.env.SCREENCI_SIGNAL_LOGGING !== 'silent') {
|
|
241
|
+
logger.info(`Forcing ${activityLabel} to stop after timeout...`);
|
|
242
|
+
}
|
|
243
|
+
child.kill('SIGKILL');
|
|
244
|
+
}
|
|
245
|
+
}, 3000);
|
|
246
|
+
forceKillTimer.unref();
|
|
247
|
+
};
|
|
248
|
+
const handleSigint = () => forwardSignal('SIGINT');
|
|
249
|
+
const handleSigterm = () => forwardSignal('SIGTERM');
|
|
250
|
+
process.on('SIGINT', handleSigint);
|
|
251
|
+
process.on('SIGTERM', handleSigterm);
|
|
252
|
+
return {
|
|
253
|
+
cleanup: () => {
|
|
254
|
+
if (forceKillTimer !== null) {
|
|
255
|
+
clearTimeout(forceKillTimer);
|
|
256
|
+
}
|
|
257
|
+
process.off('SIGINT', handleSigint);
|
|
258
|
+
process.off('SIGTERM', handleSigterm);
|
|
259
|
+
},
|
|
260
|
+
getForwardedSignal: () => forwardedSignal,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
async function getPlaywrightChromiumInstallStatus(projectDir, verbose = false) {
|
|
264
|
+
try {
|
|
265
|
+
if (verbose) {
|
|
266
|
+
logger.info('Checking Playwright Chromium install with: npx playwright install --list');
|
|
267
|
+
}
|
|
268
|
+
const list = await spawnCaptured('npx', ['playwright', 'install', '--list'], projectDir);
|
|
269
|
+
if (verbose) {
|
|
270
|
+
if (list.stdout.trim() !== '')
|
|
271
|
+
logger.info(list.stdout.trimEnd());
|
|
272
|
+
if (list.stderr.trim() !== '')
|
|
273
|
+
logger.info(list.stderr.trimEnd());
|
|
274
|
+
logger.info(`Check result: exit code ${list.code ?? 'null'}`);
|
|
275
|
+
}
|
|
276
|
+
const output = `${list.stdout}\n${list.stderr}`;
|
|
277
|
+
const versionMatch = output.match(/\bchromium-(\d+)\b/i);
|
|
278
|
+
if (list.code === 0 && versionMatch) {
|
|
279
|
+
if (verbose) {
|
|
280
|
+
logger.info(`Chromium check result: Chromium found in Playwright install list (version ${versionMatch[1]})`);
|
|
281
|
+
}
|
|
282
|
+
return { needed: false, version: versionMatch[1] ?? null };
|
|
283
|
+
}
|
|
284
|
+
if (verbose) {
|
|
285
|
+
logger.info('Chromium check result: Chromium not found in Playwright install list');
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
// If the probe fails, default to asking.
|
|
290
|
+
}
|
|
291
|
+
if (verbose) {
|
|
292
|
+
logger.info('Chromium check result: unable to verify, install still recommended');
|
|
293
|
+
}
|
|
294
|
+
return { needed: true, version: null };
|
|
295
|
+
}
|
|
296
|
+
const CONTAINER_LOG_FILTER = [
|
|
297
|
+
/^Running ScreenCI /,
|
|
298
|
+
/^Using config:/,
|
|
299
|
+
/^Starting Xvfb /,
|
|
300
|
+
/^Xvfb started /,
|
|
301
|
+
/^Recording video to:/,
|
|
302
|
+
/^Recording with /,
|
|
303
|
+
/^Stopping recording\.\.\./,
|
|
304
|
+
/^FFmpeg exited /,
|
|
305
|
+
/^Video saved to:/,
|
|
306
|
+
/^Events saved to:/,
|
|
307
|
+
];
|
|
308
|
+
function spawnContainerRecording(cmd, args) {
|
|
309
|
+
return new Promise((resolve, reject) => {
|
|
310
|
+
const child = spawn(cmd, args, { stdio: ['inherit', 'pipe', 'pipe'] });
|
|
311
|
+
const childSignals = forwardChildSignals(child, 'screenci record');
|
|
312
|
+
function forwardFiltered(chunk, out) {
|
|
313
|
+
const lines = chunk.toString().split('\n');
|
|
314
|
+
for (const line of lines) {
|
|
315
|
+
if (line === '')
|
|
316
|
+
continue;
|
|
317
|
+
if (!CONTAINER_LOG_FILTER.some((re) => re.test(line.trimStart()))) {
|
|
318
|
+
out.write(line + '\n');
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
child.stdout?.on('data', (chunk) => forwardFiltered(chunk, process.stdout));
|
|
323
|
+
child.stderr?.on('data', (chunk) => forwardFiltered(chunk, process.stderr));
|
|
324
|
+
child.on('close', (code, signal) => {
|
|
325
|
+
const forwardedSignal = childSignals.getForwardedSignal();
|
|
326
|
+
childSignals.cleanup();
|
|
327
|
+
if (forwardedSignal) {
|
|
328
|
+
process.kill(process.pid, forwardedSignal);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (signal) {
|
|
332
|
+
process.kill(process.pid, signal);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
else if (code === 0) {
|
|
336
|
+
resolve();
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
reject(new Error(`${cmd} exited with code ${code}`));
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
child.on('error', (err) => {
|
|
343
|
+
childSignals.cleanup();
|
|
344
|
+
reject(err);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
}
|
|
12
348
|
function clearDirectory(dir) {
|
|
13
349
|
mkdirSync(dir, { recursive: true });
|
|
14
350
|
for (const entry of readdirSync(dir)) {
|
|
@@ -45,38 +381,6 @@ function findRepoRoot(startDir) {
|
|
|
45
381
|
current = parent;
|
|
46
382
|
}
|
|
47
383
|
}
|
|
48
|
-
function parseArgs(args) {
|
|
49
|
-
const command = args[0];
|
|
50
|
-
if (command === undefined) {
|
|
51
|
-
logger.error('Error: No command provided');
|
|
52
|
-
logger.error('Available commands: record, dev, upload-latest, init');
|
|
53
|
-
process.exit(1);
|
|
54
|
-
}
|
|
55
|
-
let configPath;
|
|
56
|
-
let noContainer = false;
|
|
57
|
-
const otherArgs = [];
|
|
58
|
-
for (let i = 1; i < args.length; i++) {
|
|
59
|
-
const arg = args[i];
|
|
60
|
-
if (arg === '--config' || arg === '-c') {
|
|
61
|
-
const nextArg = args[i + 1];
|
|
62
|
-
if (nextArg !== undefined) {
|
|
63
|
-
configPath = nextArg;
|
|
64
|
-
i++; // skip next arg
|
|
65
|
-
}
|
|
66
|
-
else {
|
|
67
|
-
logger.error('Error: --config requires a path argument');
|
|
68
|
-
process.exit(1);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
else if (arg === '--no-container') {
|
|
72
|
-
noContainer = true;
|
|
73
|
-
}
|
|
74
|
-
else if (arg !== undefined) {
|
|
75
|
-
otherArgs.push(arg);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return { command, configPath, noContainer, otherArgs };
|
|
79
|
-
}
|
|
80
384
|
async function findLatestEntry(screenciDir) {
|
|
81
385
|
let entries;
|
|
82
386
|
try {
|
|
@@ -102,58 +406,260 @@ async function findLatestEntry(screenciDir) {
|
|
|
102
406
|
}
|
|
103
407
|
return latestEntry;
|
|
104
408
|
}
|
|
105
|
-
async function
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
let resolvedPath = assetPath;
|
|
129
|
-
for (const candidate of candidates) {
|
|
130
|
-
try {
|
|
131
|
-
fileBuffer = await readFile(candidate);
|
|
132
|
-
resolvedPath = candidate;
|
|
133
|
-
break;
|
|
409
|
+
async function hashFile(filePath) {
|
|
410
|
+
return new Promise((resolveHash, reject) => {
|
|
411
|
+
const hash = createHash('sha256');
|
|
412
|
+
const stream = createReadStream(filePath);
|
|
413
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
414
|
+
stream.on('error', reject);
|
|
415
|
+
stream.on('end', () => resolveHash(hash.digest('hex')));
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
async function prepareCustomVoiceAssets(data, configDir) {
|
|
419
|
+
const customVoiceRefsByPath = new Map();
|
|
420
|
+
for (const event of data.events) {
|
|
421
|
+
if (event.type === 'captionStart' && event.translations) {
|
|
422
|
+
for (const translation of Object.values(event.translations)) {
|
|
423
|
+
if (typeof translation.voice === 'object' &&
|
|
424
|
+
translation.voice !== null &&
|
|
425
|
+
'assetPath' in translation.voice &&
|
|
426
|
+
typeof translation.voice.assetPath === 'string') {
|
|
427
|
+
const voiceRef = translation.voice;
|
|
428
|
+
const refs = customVoiceRefsByPath.get(voiceRef.assetPath) ?? [];
|
|
429
|
+
refs.push(voiceRef);
|
|
430
|
+
customVoiceRefsByPath.set(voiceRef.assetPath, refs);
|
|
431
|
+
}
|
|
134
432
|
}
|
|
135
|
-
|
|
136
|
-
|
|
433
|
+
}
|
|
434
|
+
if (event.type === 'videoCaptionStart' && event.translations) {
|
|
435
|
+
for (const translation of Object.values(event.translations)) {
|
|
436
|
+
if ('text' in translation &&
|
|
437
|
+
typeof translation.voice === 'object' &&
|
|
438
|
+
translation.voice !== null &&
|
|
439
|
+
'assetPath' in translation.voice &&
|
|
440
|
+
typeof translation.voice.assetPath === 'string') {
|
|
441
|
+
const voiceRef = translation.voice;
|
|
442
|
+
const refs = customVoiceRefsByPath.get(voiceRef.assetPath) ?? [];
|
|
443
|
+
refs.push(voiceRef);
|
|
444
|
+
customVoiceRefsByPath.set(voiceRef.assetPath, refs);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const preparedAssets = [];
|
|
450
|
+
for (const [voicePath, refs] of customVoiceRefsByPath) {
|
|
451
|
+
const resolvedFile = await readRecordingFile(voicePath, configDir);
|
|
452
|
+
if (resolvedFile === null) {
|
|
453
|
+
const existingHash = refs.find((ref) => typeof ref.assetHash === 'string')?.assetHash;
|
|
454
|
+
if (!existingHash) {
|
|
455
|
+
throw new Error(`Custom voice file not found and no cached assetHash available: ${voicePath}`);
|
|
137
456
|
}
|
|
457
|
+
logger.warn(`Custom voice file not found locally, assuming previously uploaded recording asset is valid: ${voicePath}`);
|
|
458
|
+
for (const ref of refs) {
|
|
459
|
+
ref.assetHash = existingHash;
|
|
460
|
+
}
|
|
461
|
+
preparedAssets.push({
|
|
462
|
+
fileHash: existingHash,
|
|
463
|
+
path: voicePath,
|
|
464
|
+
size: 0,
|
|
465
|
+
contentType: contentTypeForPath(voicePath),
|
|
466
|
+
});
|
|
467
|
+
continue;
|
|
138
468
|
}
|
|
139
|
-
|
|
140
|
-
|
|
469
|
+
const { buffer: fileBuffer, resolvedPath } = resolvedFile;
|
|
470
|
+
const assetHash = createHash('sha256').update(fileBuffer).digest('hex');
|
|
471
|
+
const contentType = contentTypeForPath(resolvedPath);
|
|
472
|
+
for (const ref of refs) {
|
|
473
|
+
ref.assetHash = assetHash;
|
|
474
|
+
}
|
|
475
|
+
preparedAssets.push({
|
|
476
|
+
fileHash: assetHash,
|
|
477
|
+
path: voicePath,
|
|
478
|
+
size: fileBuffer.byteLength,
|
|
479
|
+
fileBuffer,
|
|
480
|
+
contentType,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
return preparedAssets;
|
|
484
|
+
}
|
|
485
|
+
async function collectUploadAssets(data, configDir) {
|
|
486
|
+
const assets = new Map();
|
|
487
|
+
for (const event of data.events) {
|
|
488
|
+
if (event.type === 'assetStart') {
|
|
489
|
+
if (assets.has(`name:${event.name}`))
|
|
490
|
+
continue;
|
|
491
|
+
const resolvedFile = await readRecordingFile(event.path, configDir);
|
|
492
|
+
if (resolvedFile === null) {
|
|
493
|
+
logger.warn(`Asset file not found, skipping upload: ${event.path}`);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
const { buffer: fileBuffer, resolvedPath } = resolvedFile;
|
|
497
|
+
assets.set(`name:${event.name}`, {
|
|
498
|
+
fileHash: createHash('sha256').update(fileBuffer).digest('hex'),
|
|
499
|
+
path: event.path,
|
|
500
|
+
name: event.name,
|
|
501
|
+
size: fileBuffer.byteLength,
|
|
502
|
+
fileBuffer,
|
|
503
|
+
contentType: contentTypeForPath(resolvedPath),
|
|
504
|
+
});
|
|
141
505
|
continue;
|
|
142
506
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
507
|
+
if (event.type === 'videoCaptionStart') {
|
|
508
|
+
// Single-language: hash already computed during recording, use assetPath to read file
|
|
509
|
+
if (typeof event.assetHash === 'string' &&
|
|
510
|
+
!assets.has(`hash:${event.assetHash}`)) {
|
|
511
|
+
const resolvedFile = typeof event.assetPath === 'string'
|
|
512
|
+
? await readRecordingFile(event.assetPath, configDir)
|
|
513
|
+
: null;
|
|
514
|
+
assets.set(`hash:${event.assetHash}`, {
|
|
515
|
+
fileHash: event.assetHash,
|
|
516
|
+
path: event.assetPath ?? event.assetHash,
|
|
517
|
+
size: resolvedFile?.buffer.byteLength ?? 0,
|
|
518
|
+
...(resolvedFile !== null && {
|
|
519
|
+
fileBuffer: resolvedFile.buffer,
|
|
520
|
+
contentType: contentTypeForPath(resolvedFile.resolvedPath),
|
|
521
|
+
}),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
// Multi-language: each translation carries its own hash
|
|
525
|
+
if (event.translations) {
|
|
526
|
+
for (const translation of Object.values(event.translations)) {
|
|
527
|
+
if (typeof translation === 'object' &&
|
|
528
|
+
translation !== null &&
|
|
529
|
+
'assetHash' in translation &&
|
|
530
|
+
typeof translation.assetHash === 'string' &&
|
|
531
|
+
!assets.has(`hash:${translation.assetHash}`)) {
|
|
532
|
+
const resolvedFile = 'assetPath' in translation &&
|
|
533
|
+
typeof translation.assetPath === 'string'
|
|
534
|
+
? await readRecordingFile(translation.assetPath, configDir)
|
|
535
|
+
: null;
|
|
536
|
+
assets.set(`hash:${translation.assetHash}`, {
|
|
537
|
+
fileHash: translation.assetHash,
|
|
538
|
+
path: translation.assetPath ??
|
|
539
|
+
translation.assetHash,
|
|
540
|
+
size: resolvedFile?.buffer.byteLength ?? 0,
|
|
541
|
+
...(resolvedFile !== null && {
|
|
542
|
+
fileBuffer: resolvedFile.buffer,
|
|
543
|
+
contentType: contentTypeForPath(resolvedFile.resolvedPath),
|
|
544
|
+
}),
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
for (const asset of await prepareCustomVoiceAssets(data, configDir)) {
|
|
552
|
+
assets.set(`path:${asset.path}`, asset);
|
|
553
|
+
}
|
|
554
|
+
return [...assets.values()];
|
|
555
|
+
}
|
|
556
|
+
export function stripVoicePath(voice) {
|
|
557
|
+
if (typeof voice !== 'string') {
|
|
558
|
+
return { assetHash: voice.assetHash };
|
|
559
|
+
}
|
|
560
|
+
return voice;
|
|
561
|
+
}
|
|
562
|
+
export function annotateRecordingDataWithAssetHashes(data, assets) {
|
|
563
|
+
const byName = new Map();
|
|
564
|
+
for (const asset of assets) {
|
|
565
|
+
if (typeof asset.name === 'string')
|
|
566
|
+
byName.set(asset.name, asset.fileHash);
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
...data,
|
|
570
|
+
events: data.events.map((event) => {
|
|
571
|
+
if (event.type === 'assetStart') {
|
|
572
|
+
const fileHash = byName.get(event.name);
|
|
573
|
+
return fileHash ? { ...event, fileHash } : event;
|
|
574
|
+
}
|
|
575
|
+
if (event.type === 'captionStart' && event.translations) {
|
|
576
|
+
const translations = Object.fromEntries(Object.entries(event.translations).map(([language, translation]) => {
|
|
577
|
+
if (translation.voice === undefined) {
|
|
578
|
+
return [language, translation];
|
|
579
|
+
}
|
|
580
|
+
return [
|
|
581
|
+
language,
|
|
582
|
+
{
|
|
583
|
+
...translation,
|
|
584
|
+
voice: stripVoicePath(translation.voice),
|
|
585
|
+
},
|
|
586
|
+
];
|
|
587
|
+
}));
|
|
588
|
+
return { ...event, translations };
|
|
589
|
+
}
|
|
590
|
+
if (event.type !== 'videoCaptionStart')
|
|
591
|
+
return event;
|
|
592
|
+
// Strip assetPath from translations — hash was already computed during recording
|
|
593
|
+
if (event.translations) {
|
|
594
|
+
const translations = Object.fromEntries(Object.entries(event.translations).map(([language, translation]) => {
|
|
595
|
+
if ('assetHash' in translation) {
|
|
596
|
+
const { assetPath: _removed, ...rest } = translation;
|
|
597
|
+
return [language, rest];
|
|
598
|
+
}
|
|
599
|
+
if ('voice' in translation) {
|
|
600
|
+
return [
|
|
601
|
+
language,
|
|
602
|
+
{
|
|
603
|
+
...translation,
|
|
604
|
+
...(translation.voice !== undefined
|
|
605
|
+
? { voice: stripVoicePath(translation.voice) }
|
|
606
|
+
: {}),
|
|
607
|
+
},
|
|
608
|
+
];
|
|
609
|
+
}
|
|
610
|
+
return [language, translation];
|
|
611
|
+
}));
|
|
612
|
+
return { ...event, translations };
|
|
613
|
+
}
|
|
614
|
+
// Single-language: strip assetPath, keep assetHash
|
|
615
|
+
if (typeof event.assetHash === 'string') {
|
|
616
|
+
const { assetPath: _removed, ...rest } = event;
|
|
617
|
+
return rest;
|
|
618
|
+
}
|
|
619
|
+
return event;
|
|
620
|
+
}),
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
function hint401(status, secret) {
|
|
624
|
+
if (status !== 401 || !secret)
|
|
625
|
+
return '';
|
|
626
|
+
const frontendUrl = getDevFrontendUrl();
|
|
627
|
+
return `\nThe secret may have been deleted or belongs to a different organisation. Check your secrets at ${frontendUrl}/secrets`;
|
|
628
|
+
}
|
|
629
|
+
async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIfAborted) {
|
|
630
|
+
for (const asset of assets) {
|
|
631
|
+
throwIfAborted();
|
|
156
632
|
try {
|
|
633
|
+
const checkRes = await fetch(`${apiUrl}/cli/upload/${recordingId}/asset/check`, {
|
|
634
|
+
method: 'POST',
|
|
635
|
+
headers: {
|
|
636
|
+
'Content-Type': 'application/json',
|
|
637
|
+
'X-ScreenCI-Secret': secret,
|
|
638
|
+
},
|
|
639
|
+
body: JSON.stringify({
|
|
640
|
+
fileHash: asset.fileHash,
|
|
641
|
+
contentType: asset.contentType,
|
|
642
|
+
size: asset.size,
|
|
643
|
+
path: asset.path,
|
|
644
|
+
...(typeof asset.name === 'string' ? { name: asset.name } : {}),
|
|
645
|
+
}),
|
|
646
|
+
signal,
|
|
647
|
+
});
|
|
648
|
+
if (!checkRes.ok) {
|
|
649
|
+
const text = await checkRes.text();
|
|
650
|
+
logger.warn(`Failed to check asset ${asset.path}: ${checkRes.status} ${text}${hint401(checkRes.status, secret)}`);
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
const checkBody = (await checkRes.json());
|
|
654
|
+
if (checkBody.exists) {
|
|
655
|
+
logger.info(`Asset already exists: ${asset.path}`);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
if (!asset.fileBuffer || !asset.contentType) {
|
|
659
|
+
logger.warn(`Asset bytes not available for upload and backend does not have it yet: ${asset.path}`);
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
throwIfAborted();
|
|
157
663
|
const res = await fetch(`${apiUrl}/cli/upload/${recordingId}/asset`, {
|
|
158
664
|
method: 'PUT',
|
|
159
665
|
headers: {
|
|
@@ -161,100 +667,209 @@ async function uploadAssets(data, apiUrl, secret, recordingId, configDir) {
|
|
|
161
667
|
'X-ScreenCI-Secret': secret,
|
|
162
668
|
},
|
|
163
669
|
body: JSON.stringify({
|
|
164
|
-
|
|
165
|
-
fileBase64: fileBuffer.toString('base64'),
|
|
166
|
-
contentType,
|
|
167
|
-
|
|
670
|
+
fileHash: asset.fileHash,
|
|
671
|
+
fileBase64: asset.fileBuffer.toString('base64'),
|
|
672
|
+
contentType: asset.contentType,
|
|
673
|
+
size: asset.size,
|
|
674
|
+
path: asset.path,
|
|
675
|
+
...(typeof asset.name === 'string' ? { name: asset.name } : {}),
|
|
168
676
|
}),
|
|
677
|
+
signal,
|
|
169
678
|
});
|
|
170
679
|
if (!res.ok) {
|
|
171
680
|
const text = await res.text();
|
|
172
|
-
|
|
681
|
+
if (res.status === 409 && text.includes('already exists')) {
|
|
682
|
+
logger.info(`Asset already exists: ${asset.path}`);
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
logger.warn(`Failed to upload asset ${asset.path}: ${res.status} ${text}${hint401(res.status, secret)}`);
|
|
686
|
+
}
|
|
173
687
|
}
|
|
174
688
|
else {
|
|
175
|
-
logger.info(`Asset uploaded: ${
|
|
689
|
+
logger.info(`Asset uploaded: ${asset.path}`);
|
|
176
690
|
}
|
|
177
691
|
}
|
|
178
692
|
catch (err) {
|
|
179
|
-
|
|
693
|
+
if (isUploadCancelledError(err)) {
|
|
694
|
+
throw err;
|
|
695
|
+
}
|
|
696
|
+
logger.warn(`Network error uploading asset ${asset.path}:`, err);
|
|
180
697
|
}
|
|
181
698
|
}
|
|
182
699
|
}
|
|
183
700
|
async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specificEntry) {
|
|
701
|
+
const uploadAbort = createUploadAbortController('upload');
|
|
184
702
|
let entries;
|
|
185
703
|
try {
|
|
186
704
|
entries = await readdir(screenciDir);
|
|
187
705
|
}
|
|
188
706
|
catch {
|
|
189
707
|
logger.warn('No .screenci directory found, skipping upload');
|
|
190
|
-
return;
|
|
708
|
+
return null;
|
|
191
709
|
}
|
|
192
710
|
if (specificEntry !== undefined) {
|
|
193
711
|
entries = entries.filter((e) => e === specificEntry);
|
|
194
712
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const raw = await readFile(dataJsonPath, 'utf-8');
|
|
202
|
-
data = JSON.parse(raw);
|
|
203
|
-
}
|
|
204
|
-
catch {
|
|
205
|
-
logger.warn(`Failed to read ${dataJsonPath}, skipping`);
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
const videoName = data.metadata?.videoName ?? entry;
|
|
209
|
-
logger.info(`Uploading "${videoName}"...`);
|
|
210
|
-
try {
|
|
211
|
-
// Step 1: register upload and get recordingId
|
|
212
|
-
const startResponse = await fetch(`${apiUrl}/cli/upload/start`, {
|
|
213
|
-
method: 'POST',
|
|
214
|
-
headers: {
|
|
215
|
-
'Content-Type': 'application/json',
|
|
216
|
-
'X-ScreenCI-Secret': secret,
|
|
217
|
-
},
|
|
218
|
-
body: JSON.stringify({ projectName, videoName, data }),
|
|
219
|
-
});
|
|
220
|
-
if (!startResponse.ok) {
|
|
221
|
-
const text = await startResponse.text();
|
|
222
|
-
logger.warn(`Failed to start upload for "${videoName}": ${startResponse.status} ${text}`);
|
|
713
|
+
let firstProjectId = null;
|
|
714
|
+
try {
|
|
715
|
+
for (const entry of entries) {
|
|
716
|
+
uploadAbort.throwIfAborted();
|
|
717
|
+
const dataJsonPath = resolve(screenciDir, entry, 'data.json');
|
|
718
|
+
if (!existsSync(dataJsonPath))
|
|
223
719
|
continue;
|
|
720
|
+
let data;
|
|
721
|
+
try {
|
|
722
|
+
const raw = await readFile(dataJsonPath, 'utf-8');
|
|
723
|
+
data = JSON.parse(raw);
|
|
224
724
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
725
|
+
catch {
|
|
726
|
+
logger.warn(`Failed to read ${dataJsonPath}, skipping`);
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
const videoName = data.metadata?.videoName ?? entry;
|
|
730
|
+
const preparedUploadAssets = await collectUploadAssets(data, resolve(screenciDir, '..'));
|
|
731
|
+
data = annotateRecordingDataWithAssetHashes(data, preparedUploadAssets);
|
|
732
|
+
const uploadSpinner = ora(`Uploading "${videoName}"`).start();
|
|
733
|
+
try {
|
|
734
|
+
uploadAbort.throwIfAborted();
|
|
735
|
+
const recordingPath = resolve(screenciDir, entry, 'recording.mp4');
|
|
736
|
+
const recordingHash = existsSync(recordingPath)
|
|
737
|
+
? await hashFile(recordingPath)
|
|
738
|
+
: undefined;
|
|
739
|
+
// Step 1: register upload and get recordingId
|
|
740
|
+
const startResponse = await fetch(`${apiUrl}/cli/upload/start`, {
|
|
741
|
+
method: 'POST',
|
|
235
742
|
headers: {
|
|
236
|
-
'Content-Type': '
|
|
237
|
-
'Content-Length': String(fileStat.size),
|
|
743
|
+
'Content-Type': 'application/json',
|
|
238
744
|
'X-ScreenCI-Secret': secret,
|
|
239
745
|
},
|
|
240
|
-
body:
|
|
241
|
-
|
|
242
|
-
|
|
746
|
+
body: JSON.stringify({
|
|
747
|
+
projectName,
|
|
748
|
+
videoName,
|
|
749
|
+
data,
|
|
750
|
+
...(recordingHash !== undefined ? { recordingHash } : {}),
|
|
751
|
+
expectedAssets: preparedUploadAssets.map((asset) => ({
|
|
752
|
+
fileHash: asset.fileHash,
|
|
753
|
+
size: asset.size,
|
|
754
|
+
path: asset.path,
|
|
755
|
+
...(typeof asset.contentType === 'string'
|
|
756
|
+
? { contentType: asset.contentType }
|
|
757
|
+
: {}),
|
|
758
|
+
...(typeof asset.name === 'string' ? { name: asset.name } : {}),
|
|
759
|
+
})),
|
|
760
|
+
}),
|
|
761
|
+
signal: uploadAbort.signal,
|
|
243
762
|
});
|
|
244
|
-
if (!
|
|
245
|
-
const text = await
|
|
246
|
-
|
|
763
|
+
if (!startResponse.ok) {
|
|
764
|
+
const text = await startResponse.text();
|
|
765
|
+
uploadSpinner.fail(`Failed to upload "${videoName}"`);
|
|
766
|
+
logger.warn(`Failed to start upload for "${videoName}": ${startResponse.status} ${text}${hint401(startResponse.status, secret)}`);
|
|
247
767
|
continue;
|
|
248
768
|
}
|
|
769
|
+
const { recordingId, projectId } = (await startResponse.json());
|
|
770
|
+
if (firstProjectId === null) {
|
|
771
|
+
firstProjectId = projectId;
|
|
772
|
+
}
|
|
773
|
+
// Step 1b: upload all referenced files via the shared asset flow
|
|
774
|
+
await uploadAssets(preparedUploadAssets, apiUrl, secret, recordingId, uploadAbort.signal, uploadAbort.throwIfAborted);
|
|
775
|
+
// Step 2: stream the recording video file (if it exists)
|
|
776
|
+
if (existsSync(recordingPath)) {
|
|
777
|
+
uploadAbort.throwIfAborted();
|
|
778
|
+
const fileStat = await stat(recordingPath);
|
|
779
|
+
const stream = createReadStream(recordingPath);
|
|
780
|
+
const abortStream = () => {
|
|
781
|
+
stream.destroy(new UploadCancelledError(`Upload cancelled for "${videoName}"`));
|
|
782
|
+
};
|
|
783
|
+
uploadAbort.signal.addEventListener('abort', abortStream, {
|
|
784
|
+
once: true,
|
|
785
|
+
});
|
|
786
|
+
try {
|
|
787
|
+
const recordingResponse = await fetch(`${apiUrl}/cli/upload/${recordingId}/recording`, {
|
|
788
|
+
method: 'PUT',
|
|
789
|
+
headers: {
|
|
790
|
+
'Content-Type': 'video/mp4',
|
|
791
|
+
'Content-Length': String(fileStat.size),
|
|
792
|
+
'X-ScreenCI-Secret': secret,
|
|
793
|
+
},
|
|
794
|
+
body: stream,
|
|
795
|
+
signal: uploadAbort.signal,
|
|
796
|
+
// @ts-expect-error Node.js fetch supports duplex for streaming
|
|
797
|
+
duplex: 'half',
|
|
798
|
+
});
|
|
799
|
+
if (!recordingResponse.ok) {
|
|
800
|
+
const text = await recordingResponse.text();
|
|
801
|
+
uploadSpinner.fail(`Failed to upload "${videoName}"`);
|
|
802
|
+
logger.warn(`Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`);
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
finally {
|
|
807
|
+
uploadAbort.signal.removeEventListener('abort', abortStream);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
uploadSpinner.succeed(`Uploaded "${videoName}"`);
|
|
811
|
+
}
|
|
812
|
+
catch (err) {
|
|
813
|
+
if (isUploadCancelledError(err)) {
|
|
814
|
+
uploadSpinner.fail(`Cancelled "${videoName}"`);
|
|
815
|
+
throw err;
|
|
816
|
+
}
|
|
817
|
+
uploadSpinner.fail(`Error uploading "${videoName}"`);
|
|
818
|
+
logger.warn(`Network error uploading "${videoName}":`, err);
|
|
249
819
|
}
|
|
250
|
-
logger.info(`Uploaded "${videoName}" successfully`);
|
|
251
|
-
}
|
|
252
|
-
catch (err) {
|
|
253
|
-
logger.warn(`Network error uploading "${videoName}":`, err);
|
|
254
820
|
}
|
|
821
|
+
return firstProjectId;
|
|
255
822
|
}
|
|
823
|
+
finally {
|
|
824
|
+
uploadAbort.cleanup();
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
export function getDevBackendUrl() {
|
|
828
|
+
const devBackendPort = process.env.DEV_BACKEND_PORT;
|
|
829
|
+
return devBackendPort
|
|
830
|
+
? `http://localhost:${devBackendPort}`
|
|
831
|
+
: 'https://api.screenci.com';
|
|
832
|
+
}
|
|
833
|
+
export function getDevFrontendUrl() {
|
|
834
|
+
const devFrontendPort = process.env.DEV_FRONTEND_PORT;
|
|
835
|
+
return devFrontendPort
|
|
836
|
+
? `http://localhost:${devFrontendPort}`
|
|
837
|
+
: 'https://app.screenci.com';
|
|
256
838
|
}
|
|
257
839
|
async function uploadLatest(configPath) {
|
|
840
|
+
const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
|
|
841
|
+
const apiUrl = getDevBackendUrl();
|
|
842
|
+
const secret = process.env.SCREENCI_SECRET;
|
|
843
|
+
if (!secret) {
|
|
844
|
+
logger.error('No secret configured. Set SCREENCI_SECRET in your .env file (get it from the API Key page in the dashboard).');
|
|
845
|
+
process.exit(1);
|
|
846
|
+
}
|
|
847
|
+
const configDir = dirname(resolvedConfigPath);
|
|
848
|
+
const screenciDir = resolve(configDir, '.screenci');
|
|
849
|
+
const latestEntry = await findLatestEntry(screenciDir);
|
|
850
|
+
if (!latestEntry) {
|
|
851
|
+
logger.warn('No recordings found in .screenci directory');
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
const appUrl = getDevFrontendUrl();
|
|
855
|
+
logger.info(`Uploading latest recording: "${latestEntry}"`);
|
|
856
|
+
let projectId = null;
|
|
857
|
+
try {
|
|
858
|
+
projectId = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret, latestEntry);
|
|
859
|
+
}
|
|
860
|
+
catch (err) {
|
|
861
|
+
if (isUploadCancelledError(err)) {
|
|
862
|
+
process.exit(130);
|
|
863
|
+
}
|
|
864
|
+
throw err;
|
|
865
|
+
}
|
|
866
|
+
if (projectId !== null) {
|
|
867
|
+
logger.info('');
|
|
868
|
+
logger.info('Recording finished, results available at:');
|
|
869
|
+
logger.info(pc.cyan(`${appUrl}/project/${projectId}`));
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
async function loadScreenCIConfigAndEnv(configPath) {
|
|
258
873
|
const resolvedConfigPath = findScreenCIConfig(configPath);
|
|
259
874
|
if (!resolvedConfigPath) {
|
|
260
875
|
const errorMsg = configPath
|
|
@@ -281,32 +896,61 @@ async function uploadLatest(configPath) {
|
|
|
281
896
|
logger.warn(`Failed to load env file ${envFilePath}:`, err);
|
|
282
897
|
}
|
|
283
898
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
899
|
+
return { resolvedConfigPath, screenciConfig };
|
|
900
|
+
}
|
|
901
|
+
async function requireScreenCISecret(configPath) {
|
|
902
|
+
const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
|
|
289
903
|
const secret = process.env.SCREENCI_SECRET;
|
|
290
904
|
if (!secret) {
|
|
291
905
|
logger.error('No secret configured. Set SCREENCI_SECRET in your .env file (get it from the API Key page in the dashboard).');
|
|
292
906
|
process.exit(1);
|
|
293
907
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
908
|
+
return {
|
|
909
|
+
resolvedConfigPath,
|
|
910
|
+
screenciConfig,
|
|
911
|
+
secret,
|
|
912
|
+
apiUrl: getDevBackendUrl(),
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
async function fetchProjectInfo(configPath) {
|
|
916
|
+
const { screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath);
|
|
917
|
+
const url = new URL(`${apiUrl}/cli/project-info`);
|
|
918
|
+
url.searchParams.set('projectName', screenciConfig.projectName);
|
|
919
|
+
const res = await fetch(url.toString(), {
|
|
920
|
+
headers: {
|
|
921
|
+
'X-ScreenCI-Secret': secret,
|
|
922
|
+
},
|
|
923
|
+
});
|
|
924
|
+
if (!res.ok) {
|
|
925
|
+
const text = await res.text();
|
|
926
|
+
throw new Error(`Failed to fetch project info: ${res.status} ${text}${hint401(res.status, secret)}`);
|
|
300
927
|
}
|
|
301
|
-
|
|
302
|
-
|
|
928
|
+
return (await res.json());
|
|
929
|
+
}
|
|
930
|
+
async function printProjectInfo(configPath) {
|
|
931
|
+
const info = await fetchProjectInfo(configPath);
|
|
932
|
+
process.stdout.write(`${JSON.stringify(info, null, 2)}\n`);
|
|
933
|
+
}
|
|
934
|
+
async function updateVideoVisibility(videoId, isPublic, configPath) {
|
|
935
|
+
const { secret, apiUrl } = await requireScreenCISecret(configPath);
|
|
936
|
+
const method = isPublic ? 'PUT' : 'DELETE';
|
|
937
|
+
const res = await fetch(`${apiUrl}/cli/public-video/${videoId}`, {
|
|
938
|
+
method,
|
|
939
|
+
headers: {
|
|
940
|
+
'X-ScreenCI-Secret': secret,
|
|
941
|
+
},
|
|
942
|
+
});
|
|
943
|
+
if (!res.ok) {
|
|
944
|
+
const text = await res.text();
|
|
945
|
+
throw new Error(`Failed to ${isPublic ? 'make public' : 'make private'}: ${res.status} ${text}${hint401(res.status, secret)}`);
|
|
946
|
+
}
|
|
947
|
+
logger.info(`${isPublic ? 'Made public' : 'Made private'}: ${videoId}`);
|
|
303
948
|
}
|
|
304
949
|
function generateConfig(projectName) {
|
|
305
950
|
return `import { defineConfig } from 'screenci'
|
|
306
951
|
|
|
307
952
|
export default defineConfig({
|
|
308
953
|
projectName: ${JSON.stringify(projectName)},
|
|
309
|
-
apiUrl: process.env.SCREENCI_URL ?? 'http://localhost:8787',
|
|
310
954
|
envFile: '.env',
|
|
311
955
|
videoDir: './videos',
|
|
312
956
|
forbidOnly: !!process.env.CI,
|
|
@@ -328,11 +972,18 @@ export default defineConfig({
|
|
|
328
972
|
})
|
|
329
973
|
`;
|
|
330
974
|
}
|
|
331
|
-
function generatePackageJson(projectName, localPackagePath) {
|
|
975
|
+
function generatePackageJson(projectName, localPackagePath, includePlaywrightCli = false) {
|
|
332
976
|
const npmName = projectName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
333
977
|
const screenciVersion = localPackagePath
|
|
334
978
|
? `file:${localPackagePath}`
|
|
335
979
|
: 'latest';
|
|
980
|
+
const devDependencies = {
|
|
981
|
+
'@types/node': '^25.0.0',
|
|
982
|
+
tsx: '^4.21.0',
|
|
983
|
+
};
|
|
984
|
+
if (includePlaywrightCli) {
|
|
985
|
+
devDependencies['@playwright/cli'] = 'latest';
|
|
986
|
+
}
|
|
336
987
|
return (JSON.stringify({
|
|
337
988
|
name: npmName,
|
|
338
989
|
version: '1.0.0',
|
|
@@ -340,16 +991,13 @@ function generatePackageJson(projectName, localPackagePath) {
|
|
|
340
991
|
type: 'module',
|
|
341
992
|
scripts: {
|
|
342
993
|
record: 'screenci record',
|
|
343
|
-
|
|
994
|
+
retry: 'screenci retry',
|
|
344
995
|
dev: 'screenci dev',
|
|
345
996
|
},
|
|
346
997
|
dependencies: {
|
|
347
998
|
screenci: screenciVersion,
|
|
348
999
|
},
|
|
349
|
-
devDependencies
|
|
350
|
-
'@types/node': '^25.0.0',
|
|
351
|
-
tsx: '^4.21.0',
|
|
352
|
-
},
|
|
1000
|
+
devDependencies,
|
|
353
1001
|
}, null, 2) + '\n');
|
|
354
1002
|
}
|
|
355
1003
|
function generateDockerfile() {
|
|
@@ -443,9 +1091,9 @@ async function performBrowserLogin(appUrl) {
|
|
|
443
1091
|
const port = server.address().port;
|
|
444
1092
|
const callbackUrl = `http://localhost:${port}/callback`;
|
|
445
1093
|
const loginUrl = `${appUrl}/cli-auth?callback=${encodeURIComponent(callbackUrl)}`;
|
|
446
|
-
logger.info('Opening browser for authentication...');
|
|
447
1094
|
logger.info(`If the browser does not open automatically, visit:`);
|
|
448
|
-
logger.info(
|
|
1095
|
+
logger.info(pc.cyan(loginUrl));
|
|
1096
|
+
logger.info('');
|
|
449
1097
|
openBrowser(loginUrl);
|
|
450
1098
|
});
|
|
451
1099
|
const timeout = setTimeout(() => {
|
|
@@ -464,38 +1112,19 @@ video('Example video', async ({ page }) => {
|
|
|
464
1112
|
})
|
|
465
1113
|
`;
|
|
466
1114
|
}
|
|
467
|
-
async function promptLine(question) {
|
|
468
|
-
const rl = createInterface({
|
|
469
|
-
input: process.stdin,
|
|
470
|
-
output: process.stdout,
|
|
471
|
-
});
|
|
472
|
-
try {
|
|
473
|
-
const answer = await rl.question(question);
|
|
474
|
-
return answer.trim();
|
|
475
|
-
}
|
|
476
|
-
finally {
|
|
477
|
-
rl.close();
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
1115
|
async function promptProjectName() {
|
|
481
|
-
return
|
|
1116
|
+
return input({ message: 'Project name:' });
|
|
482
1117
|
}
|
|
483
|
-
function
|
|
484
|
-
return name
|
|
485
|
-
.toLowerCase()
|
|
486
|
-
.replace(/\s+/g, '-')
|
|
487
|
-
.replace(/[^a-z0-9-]/g, '-')
|
|
488
|
-
.replace(/-+/g, '-')
|
|
489
|
-
.replace(/^-|-$/g, '');
|
|
1118
|
+
function getProjectDirName(name) {
|
|
1119
|
+
return name.toLowerCase().replace(/\s+/g, '-');
|
|
490
1120
|
}
|
|
491
1121
|
async function runInitAuth() {
|
|
492
|
-
const
|
|
493
|
-
const appUrl = process.env.SCREENCI_APP_URL ??
|
|
494
|
-
(devPort ? `http://localhost:${devPort}` : 'https://app.screenci.com');
|
|
1122
|
+
const appUrl = getDevFrontendUrl();
|
|
495
1123
|
try {
|
|
496
1124
|
const secret = await performBrowserLogin(appUrl);
|
|
497
|
-
|
|
498
|
-
|
|
1125
|
+
const savePath = resolve(process.cwd(), '.env');
|
|
1126
|
+
await writeFile(savePath, `SCREENCI_SECRET=${secret}\n`);
|
|
1127
|
+
logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
|
|
499
1128
|
}
|
|
500
1129
|
catch (err) {
|
|
501
1130
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -510,8 +1139,9 @@ function checkNodeVersion() {
|
|
|
510
1139
|
process.exit(1);
|
|
511
1140
|
}
|
|
512
1141
|
}
|
|
513
|
-
async function runInit(projectNameArg, localPackagePath) {
|
|
1142
|
+
async function runInit(projectNameArg, localPackagePath, verbose = false) {
|
|
514
1143
|
checkNodeVersion();
|
|
1144
|
+
checkContainerRuntimeForInit();
|
|
515
1145
|
let projectName = projectNameArg?.trim();
|
|
516
1146
|
if (!projectName) {
|
|
517
1147
|
projectName = await promptProjectName();
|
|
@@ -520,21 +1150,25 @@ async function runInit(projectNameArg, localPackagePath) {
|
|
|
520
1150
|
logger.error('Error: Project name is required');
|
|
521
1151
|
process.exit(1);
|
|
522
1152
|
}
|
|
523
|
-
const dirName =
|
|
1153
|
+
const dirName = getProjectDirName(projectName);
|
|
524
1154
|
const projectDir = resolve(process.cwd(), dirName);
|
|
525
1155
|
if (existsSync(projectDir)) {
|
|
526
1156
|
logger.error(`Error: Directory "${dirName}" already exists`);
|
|
527
1157
|
process.exit(1);
|
|
528
1158
|
}
|
|
1159
|
+
const shouldAddPlaywrightCli = await confirm({
|
|
1160
|
+
message: 'Do you want to write videos with an AI agent based on a URL? playwright-cli is recommended and will be added as a dev dependency.',
|
|
1161
|
+
default: true,
|
|
1162
|
+
});
|
|
529
1163
|
await mkdir(resolve(projectDir, 'videos'), { recursive: true });
|
|
530
1164
|
await mkdir(resolve(projectDir, '.github', 'workflows'), { recursive: true });
|
|
531
1165
|
await writeFile(resolve(projectDir, 'screenci.config.ts'), generateConfig(projectName));
|
|
532
|
-
await writeFile(resolve(projectDir, 'package.json'), generatePackageJson(dirName, localPackagePath));
|
|
1166
|
+
await writeFile(resolve(projectDir, 'package.json'), generatePackageJson(dirName, localPackagePath, shouldAddPlaywrightCli));
|
|
533
1167
|
await writeFile(resolve(projectDir, 'Dockerfile'), generateDockerfile());
|
|
534
1168
|
await writeFile(resolve(projectDir, '.gitignore'), generateGitignore());
|
|
535
1169
|
await writeFile(resolve(projectDir, 'videos', 'example.video.ts'), generateExampleVideo());
|
|
536
1170
|
await writeFile(resolve(projectDir, '.github', 'workflows', 'record.yml'), generateGithubAction());
|
|
537
|
-
logger.info(`Initialized screenci project "${projectName}" in ${
|
|
1171
|
+
logger.info(`Initialized screenci project "${projectName}" in ${projectDir}/`);
|
|
538
1172
|
logger.info('Files created:');
|
|
539
1173
|
logger.info(' screenci.config.ts');
|
|
540
1174
|
logger.info(' package.json');
|
|
@@ -543,141 +1177,401 @@ async function runInit(projectNameArg, localPackagePath) {
|
|
|
543
1177
|
logger.info(' videos/example.video.ts');
|
|
544
1178
|
logger.info(' .github/workflows/record.yml');
|
|
545
1179
|
logger.info('');
|
|
546
|
-
logger.info('
|
|
547
|
-
|
|
1180
|
+
logger.info('screenci requires dependencies to be installed.');
|
|
1181
|
+
const shouldInstallSkills = await confirm({
|
|
1182
|
+
message: "Add ScreenCI skills now with 'npx --yes skills add screenci/screenci --skill screenci --skill playwright-cli -y'?",
|
|
1183
|
+
default: true,
|
|
1184
|
+
});
|
|
1185
|
+
if (shouldInstallSkills) {
|
|
1186
|
+
if (verbose) {
|
|
1187
|
+
logger.info("Running 'npx --yes skills add screenci/screenci --skill screenci --skill playwright-cli -y'...");
|
|
1188
|
+
await spawnInherited('npx', [
|
|
1189
|
+
'--yes',
|
|
1190
|
+
'skills',
|
|
1191
|
+
'add',
|
|
1192
|
+
'screenci/screenci',
|
|
1193
|
+
'--skill',
|
|
1194
|
+
'screenci',
|
|
1195
|
+
'--skill',
|
|
1196
|
+
'playwright-cli',
|
|
1197
|
+
'-y',
|
|
1198
|
+
], projectDir, 'screenci init');
|
|
1199
|
+
}
|
|
1200
|
+
else {
|
|
1201
|
+
const spinner = ora('Adding ScreenCI skills...').start();
|
|
1202
|
+
try {
|
|
1203
|
+
await spawnSilent('npx', [
|
|
1204
|
+
'--yes',
|
|
1205
|
+
'skills',
|
|
1206
|
+
'add',
|
|
1207
|
+
'screenci/screenci',
|
|
1208
|
+
'--skill',
|
|
1209
|
+
'screenci',
|
|
1210
|
+
'--skill',
|
|
1211
|
+
'playwright-cli',
|
|
1212
|
+
'-y',
|
|
1213
|
+
], projectDir);
|
|
1214
|
+
spinner.succeed('ScreenCI skills added');
|
|
1215
|
+
}
|
|
1216
|
+
catch (err) {
|
|
1217
|
+
spinner.fail('ScreenCI skills install failed');
|
|
1218
|
+
throw err;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
const shouldInstall = await confirm({
|
|
1223
|
+
message: "Run 'npm install' now?",
|
|
1224
|
+
default: true,
|
|
1225
|
+
});
|
|
1226
|
+
let chromiumReady = false;
|
|
1227
|
+
if (shouldInstall) {
|
|
1228
|
+
if (verbose) {
|
|
1229
|
+
logger.info("Running 'npm install'...");
|
|
1230
|
+
await spawnInherited('npm', ['install'], projectDir, 'screenci init');
|
|
1231
|
+
}
|
|
1232
|
+
else {
|
|
1233
|
+
const spinner = ora('Running npm install...').start();
|
|
1234
|
+
try {
|
|
1235
|
+
await spawnSilent('npm', ['install', '--prefix', projectDir]);
|
|
1236
|
+
spinner.succeed('npm install complete');
|
|
1237
|
+
}
|
|
1238
|
+
catch (err) {
|
|
1239
|
+
spinner.fail('npm install failed');
|
|
1240
|
+
throw err;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
const chromiumInstallStatus = await getPlaywrightChromiumInstallStatus(projectDir, verbose);
|
|
1244
|
+
if (!chromiumInstallStatus.needed) {
|
|
1245
|
+
chromiumReady = true;
|
|
1246
|
+
const versionText = chromiumInstallStatus.version
|
|
1247
|
+
? `, version ${chromiumInstallStatus.version} already installed.`
|
|
1248
|
+
: ' because it is already installed.';
|
|
1249
|
+
logger.info(`${pc.green('✔')} Skipping Chromium installation${versionText}`);
|
|
1250
|
+
}
|
|
1251
|
+
else {
|
|
1252
|
+
logger.info('Local development requires Chromium for Playwright.');
|
|
1253
|
+
const shouldInstallChromium = await confirm({
|
|
1254
|
+
message: "Run 'npx playwright install chromium --with-deps' now?",
|
|
1255
|
+
default: true,
|
|
1256
|
+
});
|
|
1257
|
+
if (shouldInstallChromium) {
|
|
1258
|
+
logger.info("Running 'npx playwright install chromium --with-deps'...");
|
|
1259
|
+
await spawnInherited('npx', ['playwright', 'install', 'chromium', '--with-deps'], projectDir, 'screenci init');
|
|
1260
|
+
chromiumReady = true;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
548
1264
|
logger.info('');
|
|
549
1265
|
logger.info('Next steps:');
|
|
550
1266
|
logger.info(` cd ${dirName}`);
|
|
551
|
-
|
|
1267
|
+
if (!shouldInstallSkills) {
|
|
1268
|
+
logger.info(' npx --yes skills add screenci/screenci --skill screenci --skill playwright-cli -y');
|
|
1269
|
+
}
|
|
1270
|
+
if (!shouldInstall) {
|
|
1271
|
+
logger.info(' npm install');
|
|
1272
|
+
}
|
|
1273
|
+
if (!shouldInstall || !chromiumReady) {
|
|
1274
|
+
logger.info(' npx playwright install chromium --with-deps');
|
|
1275
|
+
}
|
|
1276
|
+
logger.info(' npx screenci record');
|
|
552
1277
|
}
|
|
553
1278
|
export async function main() {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
catch (err) {
|
|
584
|
-
if (!process.env.SCREENCI_SECRET) {
|
|
585
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
586
|
-
logger.error(`Failed to acquire secret: ${msg}`);
|
|
587
|
-
process.exit(1);
|
|
588
|
-
}
|
|
589
|
-
// Config import failed but SCREENCI_SECRET is already in env — continue
|
|
590
|
-
}
|
|
591
|
-
if (!process.env.SCREENCI_SECRET) {
|
|
592
|
-
logger.info('SCREENCI_SECRET not found. Opening browser to sign in and select a plan...');
|
|
593
|
-
const devPort = process.env.DEV_PORT;
|
|
594
|
-
const appUrl = process.env.SCREENCI_APP_URL ??
|
|
595
|
-
(devPort
|
|
596
|
-
? `http://localhost:${devPort}`
|
|
597
|
-
: 'https://app.screenci.com');
|
|
598
|
-
const secret = await performBrowserLogin(appUrl);
|
|
599
|
-
const savePath = envFilePath ?? resolve(dirname(resolvedConfigForSecret), '.env');
|
|
600
|
-
await writeFile(savePath, `SCREENCI_SECRET=${secret}\n`);
|
|
601
|
-
process.env.SCREENCI_SECRET = secret;
|
|
602
|
-
logger.info('API key saved.');
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
if (useContainer) {
|
|
607
|
-
await runWithContainer(otherArgs, configPath);
|
|
608
|
-
}
|
|
609
|
-
else {
|
|
610
|
-
await run(command, otherArgs, configPath);
|
|
611
|
-
}
|
|
612
|
-
// Upload only from the host, not from inside the container
|
|
613
|
-
if (process.env.SCREENCI_IN_CONTAINER === 'true')
|
|
614
|
-
break;
|
|
615
|
-
// After recording, upload results to Convex if configured
|
|
616
|
-
const resolvedConfigPath = findScreenCIConfig(configPath);
|
|
617
|
-
if (resolvedConfigPath) {
|
|
1279
|
+
if (process.argv.length <= 2) {
|
|
1280
|
+
logger.error('Error: No command provided');
|
|
1281
|
+
logger.error('Available commands: record, dev, test, info, make-public, make-private, retry, init');
|
|
1282
|
+
process.exit(1);
|
|
1283
|
+
}
|
|
1284
|
+
const program = new Command();
|
|
1285
|
+
program.name('screenci');
|
|
1286
|
+
program.exitOverride();
|
|
1287
|
+
// record command — playwright args pass through as-is
|
|
1288
|
+
program
|
|
1289
|
+
.command('record [playwrightArgs...]')
|
|
1290
|
+
.description('Record videos using Playwright')
|
|
1291
|
+
.allowUnknownOption(true)
|
|
1292
|
+
.action(async () => {
|
|
1293
|
+
const parsed = parseRecordCliArgs(getSubcommandArgv('record'));
|
|
1294
|
+
if (parsed.forcedRuntime === 'both') {
|
|
1295
|
+
logger.error('Error: --podman and --docker cannot be used together');
|
|
1296
|
+
process.exit(1);
|
|
1297
|
+
}
|
|
1298
|
+
const useContainer = !parsed.noContainer && process.env.SCREENCI_IN_CONTAINER !== 'true';
|
|
1299
|
+
// Validate early so we don't build the container unnecessarily
|
|
1300
|
+
if (useContainer) {
|
|
1301
|
+
validateArgs(parsed.otherArgs);
|
|
1302
|
+
}
|
|
1303
|
+
// On the host, acquire secret before recording if missing
|
|
1304
|
+
if (process.env.SCREENCI_IN_CONTAINER !== 'true') {
|
|
1305
|
+
const resolvedConfigForSecret = findScreenCIConfig(parsed.configPath);
|
|
1306
|
+
if (resolvedConfigForSecret) {
|
|
1307
|
+
let envFilePath = null;
|
|
618
1308
|
try {
|
|
619
|
-
const configModule = await import(
|
|
1309
|
+
const configModule = await import(resolvedConfigForSecret);
|
|
620
1310
|
const screenciConfig = configModule.default;
|
|
621
|
-
|
|
622
|
-
|
|
1311
|
+
envFilePath = screenciConfig.envFile
|
|
1312
|
+
? resolve(dirname(resolvedConfigForSecret), screenciConfig.envFile)
|
|
1313
|
+
: null;
|
|
1314
|
+
if (envFilePath) {
|
|
623
1315
|
try {
|
|
624
1316
|
process.loadEnvFile(envFilePath);
|
|
625
1317
|
}
|
|
626
|
-
catch
|
|
627
|
-
|
|
1318
|
+
catch {
|
|
1319
|
+
// env file may not exist yet
|
|
628
1320
|
}
|
|
629
1321
|
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1322
|
+
}
|
|
1323
|
+
catch (err) {
|
|
1324
|
+
if (!process.env.SCREENCI_SECRET) {
|
|
1325
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1326
|
+
logger.error(`Failed to acquire secret: ${msg}`);
|
|
1327
|
+
process.exit(1);
|
|
1328
|
+
}
|
|
1329
|
+
// Config import failed but SCREENCI_SECRET is already in env — continue
|
|
1330
|
+
}
|
|
1331
|
+
if (!process.env.SCREENCI_SECRET) {
|
|
1332
|
+
logger.info('No SCREENCI_SECRET in .env file, opening browser for authentication...');
|
|
1333
|
+
const appUrl = getDevFrontendUrl();
|
|
1334
|
+
const secret = await performBrowserLogin(appUrl);
|
|
1335
|
+
const savePath = envFilePath ?? resolve(dirname(resolvedConfigForSecret), '.env');
|
|
1336
|
+
await writeFile(savePath, `SCREENCI_SECRET=${secret}\n`);
|
|
1337
|
+
process.env.SCREENCI_SECRET = secret;
|
|
1338
|
+
logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
if (useContainer) {
|
|
1343
|
+
await runWithContainer(parsed.otherArgs, parsed.configPath, parsed.imageTag, parsed.verbose, parsed.forcedRuntime);
|
|
1344
|
+
}
|
|
1345
|
+
else {
|
|
1346
|
+
await run('record', parsed.otherArgs, parsed.configPath);
|
|
1347
|
+
}
|
|
1348
|
+
// Upload only from the host, not from inside the container
|
|
1349
|
+
if (process.env.SCREENCI_IN_CONTAINER === 'true')
|
|
1350
|
+
return;
|
|
1351
|
+
// After recording, upload results to API if configured
|
|
1352
|
+
const resolvedConfigPath = findScreenCIConfig(parsed.configPath);
|
|
1353
|
+
if (resolvedConfigPath) {
|
|
1354
|
+
try {
|
|
1355
|
+
const configModule = await import(resolvedConfigPath);
|
|
1356
|
+
const screenciConfig = configModule.default;
|
|
1357
|
+
if (screenciConfig.envFile) {
|
|
1358
|
+
const envFilePath = resolve(dirname(resolvedConfigPath), screenciConfig.envFile);
|
|
1359
|
+
try {
|
|
1360
|
+
process.loadEnvFile(envFilePath);
|
|
634
1361
|
}
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
logger.info('No secret configured, skipping upload. Set SCREENCI_SECRET in your .env file.');
|
|
638
|
-
break;
|
|
1362
|
+
catch (err) {
|
|
1363
|
+
logger.warn(`Failed to load env file ${envFilePath}:`, err);
|
|
639
1364
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
1365
|
+
}
|
|
1366
|
+
const apiUrl = getDevBackendUrl();
|
|
1367
|
+
const appUrl = getDevFrontendUrl();
|
|
1368
|
+
const secret = process.env.SCREENCI_SECRET;
|
|
1369
|
+
if (!secret) {
|
|
1370
|
+
logger.info('No secret configured, skipping upload. Set SCREENCI_SECRET in your .env file.');
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
const configDir = dirname(resolvedConfigPath);
|
|
1374
|
+
const screenciDir = resolve(configDir, '.screenci');
|
|
1375
|
+
let projectId = null;
|
|
1376
|
+
try {
|
|
1377
|
+
projectId = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret);
|
|
643
1378
|
}
|
|
644
1379
|
catch (err) {
|
|
645
|
-
|
|
1380
|
+
if (isUploadCancelledError(err)) {
|
|
1381
|
+
process.exit(130);
|
|
1382
|
+
}
|
|
1383
|
+
throw err;
|
|
646
1384
|
}
|
|
1385
|
+
if (projectId !== null) {
|
|
1386
|
+
logger.info('');
|
|
1387
|
+
logger.info('Recording finished, results available at:');
|
|
1388
|
+
logger.info(pc.cyan(`${appUrl}/project/${projectId}`));
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
catch (err) {
|
|
1392
|
+
logger.warn('Failed to load config for upload:', err);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
// dev command — playwright args pass through as-is
|
|
1397
|
+
program
|
|
1398
|
+
.command('dev [playwrightArgs...]')
|
|
1399
|
+
.description('Run Playwright in dev/UI mode')
|
|
1400
|
+
.allowUnknownOption(true)
|
|
1401
|
+
.action(async () => {
|
|
1402
|
+
const parsed = parseDevCliArgs(getSubcommandArgv('dev'));
|
|
1403
|
+
await run('dev', parsed.otherArgs, parsed.configPath);
|
|
1404
|
+
});
|
|
1405
|
+
program
|
|
1406
|
+
.command('test [playwrightArgs...]')
|
|
1407
|
+
.description('Run Playwright test with screenci.config.ts')
|
|
1408
|
+
.allowUnknownOption(true)
|
|
1409
|
+
.action(async () => {
|
|
1410
|
+
const parsed = parseDevCliArgs(getSubcommandArgv('test'));
|
|
1411
|
+
await run('test', parsed.otherArgs, parsed.configPath);
|
|
1412
|
+
});
|
|
1413
|
+
program
|
|
1414
|
+
.command('info')
|
|
1415
|
+
.description('Print remote project info as JSON')
|
|
1416
|
+
.option('-c, --config <path>', 'path to screenci.config.ts')
|
|
1417
|
+
.action(async (options) => {
|
|
1418
|
+
await printProjectInfo(options['config']);
|
|
1419
|
+
});
|
|
1420
|
+
program
|
|
1421
|
+
.command('make-public <id>')
|
|
1422
|
+
.description('Enable public URLs for a video; get the id from screenci info')
|
|
1423
|
+
.option('-c, --config <path>', 'path to screenci.config.ts')
|
|
1424
|
+
.action(async (id, options) => {
|
|
1425
|
+
await updateVideoVisibility(id, true, options['config']);
|
|
1426
|
+
});
|
|
1427
|
+
program
|
|
1428
|
+
.command('make-private <id>')
|
|
1429
|
+
.description('Disable public URLs for a video; get the id from screenci info')
|
|
1430
|
+
.option('-c, --config <path>', 'path to screenci.config.ts')
|
|
1431
|
+
.action(async (id, options) => {
|
|
1432
|
+
await updateVideoVisibility(id, false, options['config']);
|
|
1433
|
+
});
|
|
1434
|
+
// retry command
|
|
1435
|
+
program
|
|
1436
|
+
.command('retry')
|
|
1437
|
+
.description('Retry the latest recording upload')
|
|
1438
|
+
.option('-c, --config <path>', 'path to screenci.config.ts')
|
|
1439
|
+
.action(async (options) => {
|
|
1440
|
+
await uploadLatest(options['config']);
|
|
1441
|
+
});
|
|
1442
|
+
// init command
|
|
1443
|
+
program
|
|
1444
|
+
.command('init [name]')
|
|
1445
|
+
.description('Initialize a new screenci project')
|
|
1446
|
+
.option('--local', 'use local package path (for development)')
|
|
1447
|
+
.option('-v, --verbose', 'verbose output')
|
|
1448
|
+
.action(async (name, options) => {
|
|
1449
|
+
if (name === 'auth') {
|
|
1450
|
+
await runInitAuth();
|
|
1451
|
+
}
|
|
1452
|
+
else {
|
|
1453
|
+
let localPackagePath;
|
|
1454
|
+
if (options['local']) {
|
|
1455
|
+
const cliDir = dirname(fileURLToPath(import.meta.url));
|
|
1456
|
+
// cli.ts is at package root; dist/cli.js is one level down
|
|
1457
|
+
localPackagePath = existsSync(resolve(cliDir, 'package.json'))
|
|
1458
|
+
? cliDir
|
|
1459
|
+
: resolve(cliDir, '..');
|
|
647
1460
|
}
|
|
648
|
-
|
|
1461
|
+
await runInit(name, localPackagePath, options['verbose'] ?? false);
|
|
649
1462
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
1463
|
+
});
|
|
1464
|
+
try {
|
|
1465
|
+
await program.parseAsync(process.argv);
|
|
1466
|
+
}
|
|
1467
|
+
catch (err) {
|
|
1468
|
+
if (err instanceof CommanderError) {
|
|
1469
|
+
if (err.code === 'commander.unknownCommand') {
|
|
1470
|
+
const unknownCmd = process.argv[2] ?? '';
|
|
1471
|
+
logger.error(`Unknown command: ${unknownCmd}`);
|
|
1472
|
+
process.exit(1);
|
|
659
1473
|
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
// cli.ts is at package root; dist/cli.js is one level down
|
|
667
|
-
localPackagePath = existsSync(resolve(cliDir, 'package.json'))
|
|
668
|
-
? cliDir
|
|
669
|
-
: resolve(cliDir, '..');
|
|
670
|
-
initArgs = otherArgs.filter((_, i) => i !== localFlagIndex);
|
|
1474
|
+
if (err.code === 'commander.optionMissingArgument') {
|
|
1475
|
+
if (err.message.includes('--config') ||
|
|
1476
|
+
err.message.includes('-c, --config') ||
|
|
1477
|
+
err.message.includes("'-c'")) {
|
|
1478
|
+
logger.error('Error: --config requires a path argument');
|
|
1479
|
+
process.exit(1);
|
|
671
1480
|
}
|
|
672
|
-
|
|
1481
|
+
logger.error(`Error: ${err.message}`);
|
|
1482
|
+
process.exit(1);
|
|
673
1483
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
logger.error(
|
|
1484
|
+
if (err.code === 'commander.help' ||
|
|
1485
|
+
err.code === 'commander.helpDisplayed') {
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
logger.error(`Error: ${err.message}`);
|
|
679
1489
|
process.exit(1);
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
throw err;
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
function getSubcommandArgv(command) {
|
|
1496
|
+
const argv = process.argv.slice(2);
|
|
1497
|
+
const commandIndex = argv.indexOf(command);
|
|
1498
|
+
return commandIndex === -1 ? [] : argv.slice(commandIndex + 1);
|
|
1499
|
+
}
|
|
1500
|
+
function parseRecordCliArgs(args) {
|
|
1501
|
+
let configPath;
|
|
1502
|
+
let noContainer = false;
|
|
1503
|
+
let imageTag;
|
|
1504
|
+
let verbose = false;
|
|
1505
|
+
let forcedRuntime;
|
|
1506
|
+
const otherArgs = [];
|
|
1507
|
+
for (let i = 0; i < args.length; i++) {
|
|
1508
|
+
const arg = args[i];
|
|
1509
|
+
if (arg === undefined)
|
|
1510
|
+
continue;
|
|
1511
|
+
if (arg === '--config' || arg === '-c') {
|
|
1512
|
+
const nextArg = args[i + 1];
|
|
1513
|
+
if (nextArg === undefined) {
|
|
1514
|
+
logger.error('Error: --config requires a path argument');
|
|
1515
|
+
process.exit(1);
|
|
1516
|
+
}
|
|
1517
|
+
configPath = nextArg;
|
|
1518
|
+
i++;
|
|
1519
|
+
}
|
|
1520
|
+
else if (arg === '--no-container') {
|
|
1521
|
+
noContainer = true;
|
|
1522
|
+
}
|
|
1523
|
+
else if (arg === '--verbose' || arg === '-v') {
|
|
1524
|
+
verbose = true;
|
|
1525
|
+
}
|
|
1526
|
+
else if (arg === '--podman') {
|
|
1527
|
+
forcedRuntime = forcedRuntime === 'docker' ? 'both' : 'podman';
|
|
1528
|
+
}
|
|
1529
|
+
else if (arg === '--docker') {
|
|
1530
|
+
forcedRuntime = forcedRuntime === 'podman' ? 'both' : 'docker';
|
|
1531
|
+
}
|
|
1532
|
+
else if (arg === '--tag') {
|
|
1533
|
+
const nextArg = args[i + 1];
|
|
1534
|
+
if (nextArg === undefined) {
|
|
1535
|
+
logger.error('Error: --tag requires a tag argument');
|
|
1536
|
+
process.exit(1);
|
|
1537
|
+
}
|
|
1538
|
+
imageTag = nextArg;
|
|
1539
|
+
i++;
|
|
1540
|
+
}
|
|
1541
|
+
else {
|
|
1542
|
+
otherArgs.push(arg);
|
|
1543
|
+
}
|
|
680
1544
|
}
|
|
1545
|
+
return {
|
|
1546
|
+
configPath,
|
|
1547
|
+
noContainer,
|
|
1548
|
+
imageTag,
|
|
1549
|
+
verbose,
|
|
1550
|
+
forcedRuntime,
|
|
1551
|
+
otherArgs,
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
function parseDevCliArgs(args) {
|
|
1555
|
+
let configPath;
|
|
1556
|
+
const otherArgs = [];
|
|
1557
|
+
for (let i = 0; i < args.length; i++) {
|
|
1558
|
+
const arg = args[i];
|
|
1559
|
+
if (arg === undefined)
|
|
1560
|
+
continue;
|
|
1561
|
+
if (arg === '--config' || arg === '-c') {
|
|
1562
|
+
const nextArg = args[i + 1];
|
|
1563
|
+
if (nextArg === undefined) {
|
|
1564
|
+
logger.error('Error: --config requires a path argument');
|
|
1565
|
+
process.exit(1);
|
|
1566
|
+
}
|
|
1567
|
+
configPath = nextArg;
|
|
1568
|
+
i++;
|
|
1569
|
+
}
|
|
1570
|
+
else {
|
|
1571
|
+
otherArgs.push(arg);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
return { configPath, otherArgs };
|
|
681
1575
|
}
|
|
682
1576
|
function validateArgs(args) {
|
|
683
1577
|
const disallowedFlags = ['--fully-parallel', '--workers', '-j', '--retries'];
|
|
@@ -698,28 +1592,22 @@ function validateArgs(args) {
|
|
|
698
1592
|
}
|
|
699
1593
|
}
|
|
700
1594
|
}
|
|
701
|
-
function spawnInherited(cmd, args) {
|
|
702
|
-
const child = spawn(cmd, args, { stdio: 'inherit' });
|
|
703
|
-
const
|
|
704
|
-
logger.info(`Received ${signal}, stopping...`);
|
|
705
|
-
if (!child.killed) {
|
|
706
|
-
child.kill(signal);
|
|
707
|
-
}
|
|
708
|
-
const forceKill = setTimeout(() => {
|
|
709
|
-
if (child.exitCode === null) {
|
|
710
|
-
logger.info('Forcing kill after timeout...');
|
|
711
|
-
child.kill('SIGKILL');
|
|
712
|
-
}
|
|
713
|
-
}, 3000);
|
|
714
|
-
forceKill.unref();
|
|
715
|
-
};
|
|
716
|
-
process.on('SIGINT', forwardSignal);
|
|
717
|
-
process.on('SIGTERM', forwardSignal);
|
|
1595
|
+
function spawnInherited(cmd, args, cwd, activityLabel = cmd) {
|
|
1596
|
+
const child = spawn(cmd, args, { stdio: 'inherit', ...(cwd ? { cwd } : {}) });
|
|
1597
|
+
const childSignals = forwardChildSignals(child, activityLabel);
|
|
718
1598
|
return new Promise((resolve, reject) => {
|
|
719
|
-
child.on('close', (code) => {
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
if (
|
|
1599
|
+
child.on('close', (code, signal) => {
|
|
1600
|
+
const forwardedSignal = childSignals.getForwardedSignal();
|
|
1601
|
+
childSignals.cleanup();
|
|
1602
|
+
if (forwardedSignal) {
|
|
1603
|
+
process.kill(process.pid, forwardedSignal);
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
if (signal) {
|
|
1607
|
+
process.kill(process.pid, signal);
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
else if (code === 0) {
|
|
723
1611
|
resolve();
|
|
724
1612
|
}
|
|
725
1613
|
else {
|
|
@@ -727,26 +1615,55 @@ function spawnInherited(cmd, args) {
|
|
|
727
1615
|
}
|
|
728
1616
|
});
|
|
729
1617
|
child.on('error', (err) => {
|
|
730
|
-
|
|
731
|
-
process.off('SIGTERM', forwardSignal);
|
|
1618
|
+
childSignals.cleanup();
|
|
732
1619
|
reject(err);
|
|
733
1620
|
});
|
|
734
1621
|
});
|
|
735
1622
|
}
|
|
736
|
-
export function detectContainerRuntime() {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
1623
|
+
export function detectContainerRuntime(forcedRuntime) {
|
|
1624
|
+
const runtimeCheck = forcedRuntime
|
|
1625
|
+
? checkContainerRuntime(forcedRuntime)
|
|
1626
|
+
: getPreferredContainerRuntime();
|
|
1627
|
+
if (runtimeCheck) {
|
|
1628
|
+
warnIfContainerRuntimeVersionIsOld(runtimeCheck);
|
|
1629
|
+
return runtimeCheck.runtime;
|
|
1630
|
+
}
|
|
1631
|
+
if (forcedRuntime) {
|
|
1632
|
+
exitContainerRuntimeNotFound(forcedRuntime);
|
|
742
1633
|
}
|
|
743
1634
|
logger.error('Error: Neither podman nor docker found.');
|
|
744
1635
|
logger.error('Please install podman (recommended) or docker to use screenci record.');
|
|
745
|
-
logger.error(
|
|
746
|
-
logger.error(' docker: https://docs.docker.com/get-docker/');
|
|
1636
|
+
logger.error(prerequisitesMessage());
|
|
747
1637
|
process.exit(1);
|
|
748
1638
|
}
|
|
749
|
-
|
|
1639
|
+
function checkContainerRuntimeForInit() {
|
|
1640
|
+
const runtimeCheck = getPreferredContainerRuntime();
|
|
1641
|
+
if (!runtimeCheck) {
|
|
1642
|
+
logger.warn('Neither podman nor docker found. Install one before running screenci record.');
|
|
1643
|
+
logger.warn(prerequisitesMessage());
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
warnIfContainerRuntimeVersionIsOld(runtimeCheck);
|
|
1647
|
+
}
|
|
1648
|
+
async function buildImage(cmd, args, label, verbose) {
|
|
1649
|
+
if (verbose) {
|
|
1650
|
+
await spawnInherited(cmd, args);
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
const spinner = ora(label).start();
|
|
1654
|
+
try {
|
|
1655
|
+
await spawnSilent(cmd, args);
|
|
1656
|
+
spinner.succeed(label);
|
|
1657
|
+
}
|
|
1658
|
+
catch (err) {
|
|
1659
|
+
spinner.fail(label);
|
|
1660
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1661
|
+
logger.error(msg);
|
|
1662
|
+
logger.error('Run again with --verbose to see the full build output');
|
|
1663
|
+
process.exit(1);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
async function runWithContainer(additionalArgs, customConfigPath, imageTag, verbose = false, forcedRuntime) {
|
|
750
1667
|
const configPath = findScreenCIConfig(customConfigPath);
|
|
751
1668
|
if (!configPath) {
|
|
752
1669
|
const errorMsg = customConfigPath
|
|
@@ -767,44 +1684,90 @@ async function runWithContainer(additionalArgs, customConfigPath) {
|
|
|
767
1684
|
logger.error('Error: Could not find repository root (.git or pnpm-workspace.yaml)');
|
|
768
1685
|
process.exit(1);
|
|
769
1686
|
}
|
|
770
|
-
const containerRuntime = detectContainerRuntime();
|
|
1687
|
+
const containerRuntime = detectContainerRuntime(forcedRuntime);
|
|
1688
|
+
const ghcrImage = 'ghcr.io/screenci/record:latest';
|
|
1689
|
+
const dockerfileVersion = parseDockerfileVersion(dockerfilePath);
|
|
771
1690
|
if (process.env['SCREENCI_LOCAL_IMAGE']) {
|
|
772
1691
|
logger.info('SCREENCI_LOCAL_IMAGE set — skipping screenci image build');
|
|
773
1692
|
}
|
|
1693
|
+
else if (imageTag !== undefined) {
|
|
1694
|
+
const remoteImage = `ghcr.io/screenci/record:${imageTag}`;
|
|
1695
|
+
const imageExists = spawnSync(containerRuntime, ['image', 'exists', remoteImage], {
|
|
1696
|
+
stdio: 'ignore',
|
|
1697
|
+
}).status === 0;
|
|
1698
|
+
logger.info(`Using image tag ${imageTag} instead of the version ${dockerfileVersion} from Dockerfile`);
|
|
1699
|
+
if (!imageExists) {
|
|
1700
|
+
await buildImage(containerRuntime, ['pull', remoteImage], 'Pulling screenci image', verbose);
|
|
1701
|
+
}
|
|
1702
|
+
await spawnSilent(containerRuntime, ['tag', remoteImage, ghcrImage]);
|
|
1703
|
+
}
|
|
774
1704
|
else {
|
|
775
1705
|
const cliDir = dirname(fileURLToPath(import.meta.url));
|
|
1706
|
+
const screenciPackageRoot = existsSync(resolve(cliDir, 'package.json'))
|
|
1707
|
+
? cliDir
|
|
1708
|
+
: resolve(cliDir, '..');
|
|
776
1709
|
const screenciDockerfilePath = resolve(cliDir, 'Dockerfile');
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
'screenci'
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
1710
|
+
if (verbose) {
|
|
1711
|
+
await spawnInherited(containerRuntime, [
|
|
1712
|
+
'build',
|
|
1713
|
+
'-f',
|
|
1714
|
+
screenciDockerfilePath,
|
|
1715
|
+
'-t',
|
|
1716
|
+
ghcrImage,
|
|
1717
|
+
screenciPackageRoot,
|
|
1718
|
+
], undefined, 'building screenci image');
|
|
1719
|
+
await spawnInherited(containerRuntime, ['build', '-f', dockerfilePath, '-t', 'screenci', configDir], undefined, 'building project image');
|
|
1720
|
+
}
|
|
1721
|
+
else {
|
|
1722
|
+
const spinner = ora('Building screenci image').start();
|
|
1723
|
+
try {
|
|
1724
|
+
await spawnSilent(containerRuntime, [
|
|
1725
|
+
'build',
|
|
1726
|
+
'-f',
|
|
1727
|
+
screenciDockerfilePath,
|
|
1728
|
+
'-t',
|
|
1729
|
+
ghcrImage,
|
|
1730
|
+
screenciPackageRoot,
|
|
1731
|
+
]);
|
|
1732
|
+
await spawnSilent(containerRuntime, [
|
|
1733
|
+
'build',
|
|
1734
|
+
'-f',
|
|
1735
|
+
dockerfilePath,
|
|
1736
|
+
'-t',
|
|
1737
|
+
'screenci',
|
|
1738
|
+
configDir,
|
|
1739
|
+
]);
|
|
1740
|
+
spinner.succeed('Building screenci image');
|
|
1741
|
+
}
|
|
1742
|
+
catch (err) {
|
|
1743
|
+
spinner.fail('Building screenci image');
|
|
1744
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1745
|
+
logger.error(msg);
|
|
1746
|
+
logger.error('Run again with --verbose to see the full build output');
|
|
1747
|
+
process.exit(1);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
if (imageTag !== undefined || process.env['SCREENCI_LOCAL_IMAGE']) {
|
|
1752
|
+
await buildImage(containerRuntime, ['build', '-f', dockerfilePath, '-t', 'screenci', configDir], 'Building project image', verbose);
|
|
1753
|
+
}
|
|
799
1754
|
clearDirectory(resolve(configDir, '.screenci'));
|
|
800
|
-
|
|
801
|
-
|
|
1755
|
+
const secret = process.env['SCREENCI_SECRET'];
|
|
1756
|
+
if (secret === undefined) {
|
|
1757
|
+
logger.error('Error: SCREENCI_SECRET is not set');
|
|
1758
|
+
process.exit(1);
|
|
1759
|
+
}
|
|
1760
|
+
await spawnContainerRecording(containerRuntime, [
|
|
802
1761
|
'run',
|
|
803
1762
|
'--rm',
|
|
804
1763
|
'-e',
|
|
805
1764
|
'SCREENCI_IN_CONTAINER=true',
|
|
806
1765
|
'-e',
|
|
807
1766
|
'SCREENCI_RECORD=true',
|
|
1767
|
+
'-e',
|
|
1768
|
+
`SCREENCI_SECRET=${secret}`,
|
|
1769
|
+
'-e',
|
|
1770
|
+
'SCREENCI_SIGNAL_LOGGING=silent',
|
|
808
1771
|
'-v',
|
|
809
1772
|
`${configDir}/.screenci:/app/.screenci`,
|
|
810
1773
|
'-v',
|
|
@@ -835,9 +1798,17 @@ async function run(command, additionalArgs, customConfigPath) {
|
|
|
835
1798
|
// For dev command: use --ui unless --headed is specified
|
|
836
1799
|
const isHeaded = additionalArgs.includes('--headed');
|
|
837
1800
|
const shouldUseUI = command === 'dev' && !isHeaded;
|
|
838
|
-
const mode = command === 'dev'
|
|
839
|
-
|
|
840
|
-
|
|
1801
|
+
const mode = command === 'dev'
|
|
1802
|
+
? isHeaded
|
|
1803
|
+
? 'headed mode'
|
|
1804
|
+
: 'UI mode'
|
|
1805
|
+
: command === 'test'
|
|
1806
|
+
? 'tests'
|
|
1807
|
+
: 'recorder';
|
|
1808
|
+
if (process.env.SCREENCI_IN_CONTAINER !== 'true') {
|
|
1809
|
+
logger.info(`Running ScreenCI ${mode} with npx...`);
|
|
1810
|
+
logger.info(`Using config: ${configPath}`);
|
|
1811
|
+
}
|
|
841
1812
|
const playwrightArgs = [
|
|
842
1813
|
'playwright',
|
|
843
1814
|
'test',
|
|
@@ -854,29 +1825,19 @@ async function run(command, additionalArgs, customConfigPath) {
|
|
|
854
1825
|
...(command === 'record' ? { SCREENCI_RECORD: 'true' } : {}),
|
|
855
1826
|
},
|
|
856
1827
|
});
|
|
857
|
-
const
|
|
858
|
-
logger.info(`Received ${signal}, stopping recording...`);
|
|
859
|
-
if (!child.killed) {
|
|
860
|
-
child.kill(signal);
|
|
861
|
-
}
|
|
862
|
-
// Force-kill after 3 s if the child hasn't actually exited yet.
|
|
863
|
-
// child.killed becomes true as soon as we send the signal, so we check
|
|
864
|
-
// child.exitCode instead — it stays null until the process truly exits.
|
|
865
|
-
// unref() so the timer doesn't keep the process alive on its own.
|
|
866
|
-
const forceKill = setTimeout(() => {
|
|
867
|
-
if (child.exitCode === null) {
|
|
868
|
-
logger.info('Forcing kill after timeout...');
|
|
869
|
-
child.kill('SIGKILL');
|
|
870
|
-
}
|
|
871
|
-
}, 3000);
|
|
872
|
-
forceKill.unref();
|
|
873
|
-
};
|
|
874
|
-
process.on('SIGINT', forwardSignal);
|
|
875
|
-
process.on('SIGTERM', forwardSignal);
|
|
1828
|
+
const childSignals = forwardChildSignals(child, `screenci ${command}`);
|
|
876
1829
|
return new Promise((resolve, reject) => {
|
|
877
|
-
child.on('close', (code) => {
|
|
878
|
-
|
|
879
|
-
|
|
1830
|
+
child.on('close', (code, signal) => {
|
|
1831
|
+
const forwardedSignal = childSignals.getForwardedSignal();
|
|
1832
|
+
childSignals.cleanup();
|
|
1833
|
+
if (forwardedSignal) {
|
|
1834
|
+
process.kill(process.pid, forwardedSignal);
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
if (signal) {
|
|
1838
|
+
process.kill(process.pid, signal);
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
880
1841
|
if (code === 0) {
|
|
881
1842
|
resolve();
|
|
882
1843
|
}
|
|
@@ -885,6 +1846,7 @@ async function run(command, additionalArgs, customConfigPath) {
|
|
|
885
1846
|
}
|
|
886
1847
|
});
|
|
887
1848
|
child.on('error', (err) => {
|
|
1849
|
+
childSignals.cleanup();
|
|
888
1850
|
reject(err);
|
|
889
1851
|
});
|
|
890
1852
|
});
|