screenci 0.0.10 → 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 +16 -2
- package/dist/cli.d.ts +21 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1199 -420
- 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 +7 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -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 +67 -25
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +34 -18
- 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 +1 -1
- package/dist/src/recording.d.ts.map +1 -1
- 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 +133 -66
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/video.d.ts.map +1 -1
- package/dist/src/video.js +28 -20
- 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 +34 -4
- 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/src/captionHash.d.ts +0 -12
- package/dist/src/captionHash.d.ts.map +0 -1
- package/dist/src/captionHash.js +0 -17
- package/dist/src/captionHash.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
import { spawn, spawnSync } from 'child_process';
|
|
3
3
|
import { createReadStream } from 'fs';
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, } from 'fs';
|
|
@@ -6,14 +6,99 @@ 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';
|
|
12
|
-
function
|
|
13
|
-
|
|
15
|
+
function resolveRecordingFileCandidates(filePath, configDir) {
|
|
16
|
+
return [
|
|
17
|
+
filePath,
|
|
18
|
+
resolve(configDir, 'videos', filePath),
|
|
19
|
+
resolve(configDir, pathRelative('/app', filePath)),
|
|
20
|
+
];
|
|
14
21
|
}
|
|
15
|
-
function
|
|
16
|
-
|
|
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
|
+
};
|
|
17
102
|
}
|
|
18
103
|
function parseDockerfileVersion(dockerfilePath) {
|
|
19
104
|
let content;
|
|
@@ -31,20 +116,183 @@ function parseDockerfileVersion(dockerfilePath) {
|
|
|
31
116
|
const match = fromLine.match(/:([^\s@]+)/);
|
|
32
117
|
return match?.[1] ?? 'unknown';
|
|
33
118
|
}
|
|
34
|
-
|
|
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) {
|
|
35
167
|
return new Promise((resolve, reject) => {
|
|
36
|
-
const child = spawn(cmd, args, { stdio: 'pipe' });
|
|
37
|
-
child
|
|
38
|
-
|
|
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) {
|
|
39
182
|
resolve();
|
|
40
183
|
}
|
|
41
184
|
else {
|
|
42
185
|
reject(new Error(`${cmd} exited with code ${code}`));
|
|
43
186
|
}
|
|
44
187
|
});
|
|
45
|
-
child.on('error',
|
|
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
|
+
});
|
|
46
223
|
});
|
|
47
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
|
+
}
|
|
48
296
|
const CONTAINER_LOG_FILTER = [
|
|
49
297
|
/^Running ScreenCI /,
|
|
50
298
|
/^Using config:/,
|
|
@@ -60,6 +308,7 @@ const CONTAINER_LOG_FILTER = [
|
|
|
60
308
|
function spawnContainerRecording(cmd, args) {
|
|
61
309
|
return new Promise((resolve, reject) => {
|
|
62
310
|
const child = spawn(cmd, args, { stdio: ['inherit', 'pipe', 'pipe'] });
|
|
311
|
+
const childSignals = forwardChildSignals(child, 'screenci record');
|
|
63
312
|
function forwardFiltered(chunk, out) {
|
|
64
313
|
const lines = chunk.toString().split('\n');
|
|
65
314
|
for (const line of lines) {
|
|
@@ -72,15 +321,28 @@ function spawnContainerRecording(cmd, args) {
|
|
|
72
321
|
}
|
|
73
322
|
child.stdout?.on('data', (chunk) => forwardFiltered(chunk, process.stdout));
|
|
74
323
|
child.stderr?.on('data', (chunk) => forwardFiltered(chunk, process.stderr));
|
|
75
|
-
child.on('close', (code) => {
|
|
76
|
-
|
|
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) {
|
|
77
336
|
resolve();
|
|
78
337
|
}
|
|
79
338
|
else {
|
|
80
339
|
reject(new Error(`${cmd} exited with code ${code}`));
|
|
81
340
|
}
|
|
82
341
|
});
|
|
83
|
-
child.on('error',
|
|
342
|
+
child.on('error', (err) => {
|
|
343
|
+
childSignals.cleanup();
|
|
344
|
+
reject(err);
|
|
345
|
+
});
|
|
84
346
|
});
|
|
85
347
|
}
|
|
86
348
|
function clearDirectory(dir) {
|
|
@@ -119,54 +381,6 @@ function findRepoRoot(startDir) {
|
|
|
119
381
|
current = parent;
|
|
120
382
|
}
|
|
121
383
|
}
|
|
122
|
-
function parseArgs(args) {
|
|
123
|
-
const command = args[0];
|
|
124
|
-
if (command === undefined) {
|
|
125
|
-
logger.error('Error: No command provided');
|
|
126
|
-
logger.error('Available commands: record, dev, upload-latest, init');
|
|
127
|
-
process.exit(1);
|
|
128
|
-
}
|
|
129
|
-
let configPath;
|
|
130
|
-
let noContainer = false;
|
|
131
|
-
let imageTag;
|
|
132
|
-
let verbose = false;
|
|
133
|
-
const otherArgs = [];
|
|
134
|
-
for (let i = 1; i < args.length; i++) {
|
|
135
|
-
const arg = args[i];
|
|
136
|
-
if (arg === '--config' || arg === '-c') {
|
|
137
|
-
const nextArg = args[i + 1];
|
|
138
|
-
if (nextArg !== undefined) {
|
|
139
|
-
configPath = nextArg;
|
|
140
|
-
i++; // skip next arg
|
|
141
|
-
}
|
|
142
|
-
else {
|
|
143
|
-
logger.error('Error: --config requires a path argument');
|
|
144
|
-
process.exit(1);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
else if (arg === '--no-container') {
|
|
148
|
-
noContainer = true;
|
|
149
|
-
}
|
|
150
|
-
else if (arg === '--verbose' || arg === '-v') {
|
|
151
|
-
verbose = true;
|
|
152
|
-
}
|
|
153
|
-
else if (arg === '--tag') {
|
|
154
|
-
const nextArg = args[i + 1];
|
|
155
|
-
if (nextArg !== undefined) {
|
|
156
|
-
imageTag = nextArg;
|
|
157
|
-
i++; // skip next arg
|
|
158
|
-
}
|
|
159
|
-
else {
|
|
160
|
-
logger.error('Error: --tag requires a tag argument');
|
|
161
|
-
process.exit(1);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
else if (arg !== undefined) {
|
|
165
|
-
otherArgs.push(arg);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
return { command, configPath, noContainer, imageTag, verbose, otherArgs };
|
|
169
|
-
}
|
|
170
384
|
async function findLatestEntry(screenciDir) {
|
|
171
385
|
let entries;
|
|
172
386
|
try {
|
|
@@ -192,58 +406,260 @@ async function findLatestEntry(screenciDir) {
|
|
|
192
406
|
}
|
|
193
407
|
return latestEntry;
|
|
194
408
|
}
|
|
195
|
-
async function
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
let resolvedPath = assetPath;
|
|
219
|
-
for (const candidate of candidates) {
|
|
220
|
-
try {
|
|
221
|
-
fileBuffer = await readFile(candidate);
|
|
222
|
-
resolvedPath = candidate;
|
|
223
|
-
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
|
+
}
|
|
224
432
|
}
|
|
225
|
-
|
|
226
|
-
|
|
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
|
+
}
|
|
227
446
|
}
|
|
228
447
|
}
|
|
229
|
-
|
|
230
|
-
|
|
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}`);
|
|
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;
|
|
468
|
+
}
|
|
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
|
+
});
|
|
231
505
|
continue;
|
|
232
506
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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();
|
|
246
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();
|
|
247
663
|
const res = await fetch(`${apiUrl}/cli/upload/${recordingId}/asset`, {
|
|
248
664
|
method: 'PUT',
|
|
249
665
|
headers: {
|
|
@@ -251,26 +667,38 @@ async function uploadAssets(data, apiUrl, secret, recordingId, configDir) {
|
|
|
251
667
|
'X-ScreenCI-Secret': secret,
|
|
252
668
|
},
|
|
253
669
|
body: JSON.stringify({
|
|
254
|
-
|
|
255
|
-
fileBase64: fileBuffer.toString('base64'),
|
|
256
|
-
contentType,
|
|
257
|
-
|
|
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 } : {}),
|
|
258
676
|
}),
|
|
677
|
+
signal,
|
|
259
678
|
});
|
|
260
679
|
if (!res.ok) {
|
|
261
680
|
const text = await res.text();
|
|
262
|
-
|
|
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
|
+
}
|
|
263
687
|
}
|
|
264
688
|
else {
|
|
265
|
-
logger.info(`Asset uploaded: ${
|
|
689
|
+
logger.info(`Asset uploaded: ${asset.path}`);
|
|
266
690
|
}
|
|
267
691
|
}
|
|
268
692
|
catch (err) {
|
|
269
|
-
|
|
693
|
+
if (isUploadCancelledError(err)) {
|
|
694
|
+
throw err;
|
|
695
|
+
}
|
|
696
|
+
logger.warn(`Network error uploading asset ${asset.path}:`, err);
|
|
270
697
|
}
|
|
271
698
|
}
|
|
272
699
|
}
|
|
273
700
|
async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specificEntry) {
|
|
701
|
+
const uploadAbort = createUploadAbortController('upload');
|
|
274
702
|
let entries;
|
|
275
703
|
try {
|
|
276
704
|
entries = await readdir(screenciDir);
|
|
@@ -283,76 +711,165 @@ async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specif
|
|
|
283
711
|
entries = entries.filter((e) => e === specificEntry);
|
|
284
712
|
}
|
|
285
713
|
let firstProjectId = null;
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
try {
|
|
292
|
-
const raw = await readFile(dataJsonPath, 'utf-8');
|
|
293
|
-
data = JSON.parse(raw);
|
|
294
|
-
}
|
|
295
|
-
catch {
|
|
296
|
-
logger.warn(`Failed to read ${dataJsonPath}, skipping`);
|
|
297
|
-
continue;
|
|
298
|
-
}
|
|
299
|
-
const videoName = data.metadata?.videoName ?? entry;
|
|
300
|
-
writeInline(`Uploading "${videoName}"...`);
|
|
301
|
-
try {
|
|
302
|
-
// Step 1: register upload and get recordingId
|
|
303
|
-
const startResponse = await fetch(`${apiUrl}/cli/upload/start`, {
|
|
304
|
-
method: 'POST',
|
|
305
|
-
headers: {
|
|
306
|
-
'Content-Type': 'application/json',
|
|
307
|
-
'X-ScreenCI-Secret': secret,
|
|
308
|
-
},
|
|
309
|
-
body: JSON.stringify({ projectName, videoName, data }),
|
|
310
|
-
});
|
|
311
|
-
if (!startResponse.ok) {
|
|
312
|
-
const text = await startResponse.text();
|
|
313
|
-
process.stdout.write('\n');
|
|
314
|
-
logger.warn(`Failed to start upload for "${videoName}": ${startResponse.status} ${text}`);
|
|
714
|
+
try {
|
|
715
|
+
for (const entry of entries) {
|
|
716
|
+
uploadAbort.throwIfAborted();
|
|
717
|
+
const dataJsonPath = resolve(screenciDir, entry, 'data.json');
|
|
718
|
+
if (!existsSync(dataJsonPath))
|
|
315
719
|
continue;
|
|
720
|
+
let data;
|
|
721
|
+
try {
|
|
722
|
+
const raw = await readFile(dataJsonPath, 'utf-8');
|
|
723
|
+
data = JSON.parse(raw);
|
|
316
724
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
await
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
const
|
|
329
|
-
|
|
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',
|
|
330
742
|
headers: {
|
|
331
|
-
'Content-Type': '
|
|
332
|
-
'Content-Length': String(fileStat.size),
|
|
743
|
+
'Content-Type': 'application/json',
|
|
333
744
|
'X-ScreenCI-Secret': secret,
|
|
334
745
|
},
|
|
335
|
-
body:
|
|
336
|
-
|
|
337
|
-
|
|
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,
|
|
338
762
|
});
|
|
339
|
-
if (!
|
|
340
|
-
const text = await
|
|
341
|
-
|
|
342
|
-
logger.warn(`Failed to upload
|
|
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)}`);
|
|
343
767
|
continue;
|
|
344
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);
|
|
345
819
|
}
|
|
346
|
-
completeInline(`Uploading "${videoName}" ✓`);
|
|
347
|
-
}
|
|
348
|
-
catch (err) {
|
|
349
|
-
process.stdout.write('\n');
|
|
350
|
-
logger.warn(`Network error uploading "${videoName}":`, err);
|
|
351
820
|
}
|
|
821
|
+
return firstProjectId;
|
|
352
822
|
}
|
|
353
|
-
|
|
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';
|
|
354
838
|
}
|
|
355
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) {
|
|
356
873
|
const resolvedConfigPath = findScreenCIConfig(configPath);
|
|
357
874
|
if (!resolvedConfigPath) {
|
|
358
875
|
const errorMsg = configPath
|
|
@@ -379,33 +896,55 @@ async function uploadLatest(configPath) {
|
|
|
379
896
|
logger.warn(`Failed to load env file ${envFilePath}:`, err);
|
|
380
897
|
}
|
|
381
898
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
899
|
+
return { resolvedConfigPath, screenciConfig };
|
|
900
|
+
}
|
|
901
|
+
async function requireScreenCISecret(configPath) {
|
|
902
|
+
const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
|
|
385
903
|
const secret = process.env.SCREENCI_SECRET;
|
|
386
904
|
if (!secret) {
|
|
387
905
|
logger.error('No secret configured. Set SCREENCI_SECRET in your .env file (get it from the API Key page in the dashboard).');
|
|
388
906
|
process.exit(1);
|
|
389
907
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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)}`);
|
|
396
927
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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)}`);
|
|
408
946
|
}
|
|
947
|
+
logger.info(`${isPublic ? 'Made public' : 'Made private'}: ${videoId}`);
|
|
409
948
|
}
|
|
410
949
|
function generateConfig(projectName) {
|
|
411
950
|
return `import { defineConfig } from 'screenci'
|
|
@@ -433,11 +972,18 @@ export default defineConfig({
|
|
|
433
972
|
})
|
|
434
973
|
`;
|
|
435
974
|
}
|
|
436
|
-
function generatePackageJson(projectName, localPackagePath) {
|
|
975
|
+
function generatePackageJson(projectName, localPackagePath, includePlaywrightCli = false) {
|
|
437
976
|
const npmName = projectName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
438
977
|
const screenciVersion = localPackagePath
|
|
439
978
|
? `file:${localPackagePath}`
|
|
440
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
|
+
}
|
|
441
987
|
return (JSON.stringify({
|
|
442
988
|
name: npmName,
|
|
443
989
|
version: '1.0.0',
|
|
@@ -445,16 +991,13 @@ function generatePackageJson(projectName, localPackagePath) {
|
|
|
445
991
|
type: 'module',
|
|
446
992
|
scripts: {
|
|
447
993
|
record: 'screenci record',
|
|
448
|
-
|
|
994
|
+
retry: 'screenci retry',
|
|
449
995
|
dev: 'screenci dev',
|
|
450
996
|
},
|
|
451
997
|
dependencies: {
|
|
452
998
|
screenci: screenciVersion,
|
|
453
999
|
},
|
|
454
|
-
devDependencies
|
|
455
|
-
'@types/node': '^25.0.0',
|
|
456
|
-
tsx: '^4.21.0',
|
|
457
|
-
},
|
|
1000
|
+
devDependencies,
|
|
458
1001
|
}, null, 2) + '\n');
|
|
459
1002
|
}
|
|
460
1003
|
function generateDockerfile() {
|
|
@@ -549,7 +1092,7 @@ async function performBrowserLogin(appUrl) {
|
|
|
549
1092
|
const callbackUrl = `http://localhost:${port}/callback`;
|
|
550
1093
|
const loginUrl = `${appUrl}/cli-auth?callback=${encodeURIComponent(callbackUrl)}`;
|
|
551
1094
|
logger.info(`If the browser does not open automatically, visit:`);
|
|
552
|
-
logger.info(loginUrl);
|
|
1095
|
+
logger.info(pc.cyan(loginUrl));
|
|
553
1096
|
logger.info('');
|
|
554
1097
|
openBrowser(loginUrl);
|
|
555
1098
|
});
|
|
@@ -569,34 +1112,14 @@ video('Example video', async ({ page }) => {
|
|
|
569
1112
|
})
|
|
570
1113
|
`;
|
|
571
1114
|
}
|
|
572
|
-
async function promptLine(question) {
|
|
573
|
-
const rl = createInterface({
|
|
574
|
-
input: process.stdin,
|
|
575
|
-
output: process.stdout,
|
|
576
|
-
});
|
|
577
|
-
try {
|
|
578
|
-
const answer = await rl.question(question);
|
|
579
|
-
return answer.trim();
|
|
580
|
-
}
|
|
581
|
-
finally {
|
|
582
|
-
rl.close();
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
1115
|
async function promptProjectName() {
|
|
586
|
-
return
|
|
1116
|
+
return input({ message: 'Project name:' });
|
|
587
1117
|
}
|
|
588
|
-
function
|
|
589
|
-
return name
|
|
590
|
-
.toLowerCase()
|
|
591
|
-
.replace(/\s+/g, '-')
|
|
592
|
-
.replace(/[^a-z0-9-]/g, '-')
|
|
593
|
-
.replace(/-+/g, '-')
|
|
594
|
-
.replace(/^-|-$/g, '');
|
|
1118
|
+
function getProjectDirName(name) {
|
|
1119
|
+
return name.toLowerCase().replace(/\s+/g, '-');
|
|
595
1120
|
}
|
|
596
1121
|
async function runInitAuth() {
|
|
597
|
-
const
|
|
598
|
-
const appUrl = process.env.SCREENCI_APP_URL ??
|
|
599
|
-
(devPort ? `http://localhost:${devPort}` : 'https://app.screenci.com');
|
|
1122
|
+
const appUrl = getDevFrontendUrl();
|
|
600
1123
|
try {
|
|
601
1124
|
const secret = await performBrowserLogin(appUrl);
|
|
602
1125
|
const savePath = resolve(process.cwd(), '.env');
|
|
@@ -616,8 +1139,9 @@ function checkNodeVersion() {
|
|
|
616
1139
|
process.exit(1);
|
|
617
1140
|
}
|
|
618
1141
|
}
|
|
619
|
-
async function runInit(projectNameArg, localPackagePath) {
|
|
1142
|
+
async function runInit(projectNameArg, localPackagePath, verbose = false) {
|
|
620
1143
|
checkNodeVersion();
|
|
1144
|
+
checkContainerRuntimeForInit();
|
|
621
1145
|
let projectName = projectNameArg?.trim();
|
|
622
1146
|
if (!projectName) {
|
|
623
1147
|
projectName = await promptProjectName();
|
|
@@ -626,21 +1150,25 @@ async function runInit(projectNameArg, localPackagePath) {
|
|
|
626
1150
|
logger.error('Error: Project name is required');
|
|
627
1151
|
process.exit(1);
|
|
628
1152
|
}
|
|
629
|
-
const dirName =
|
|
1153
|
+
const dirName = getProjectDirName(projectName);
|
|
630
1154
|
const projectDir = resolve(process.cwd(), dirName);
|
|
631
1155
|
if (existsSync(projectDir)) {
|
|
632
1156
|
logger.error(`Error: Directory "${dirName}" already exists`);
|
|
633
1157
|
process.exit(1);
|
|
634
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
|
+
});
|
|
635
1163
|
await mkdir(resolve(projectDir, 'videos'), { recursive: true });
|
|
636
1164
|
await mkdir(resolve(projectDir, '.github', 'workflows'), { recursive: true });
|
|
637
1165
|
await writeFile(resolve(projectDir, 'screenci.config.ts'), generateConfig(projectName));
|
|
638
|
-
await writeFile(resolve(projectDir, 'package.json'), generatePackageJson(dirName, localPackagePath));
|
|
1166
|
+
await writeFile(resolve(projectDir, 'package.json'), generatePackageJson(dirName, localPackagePath, shouldAddPlaywrightCli));
|
|
639
1167
|
await writeFile(resolve(projectDir, 'Dockerfile'), generateDockerfile());
|
|
640
1168
|
await writeFile(resolve(projectDir, '.gitignore'), generateGitignore());
|
|
641
1169
|
await writeFile(resolve(projectDir, 'videos', 'example.video.ts'), generateExampleVideo());
|
|
642
1170
|
await writeFile(resolve(projectDir, '.github', 'workflows', 'record.yml'), generateGithubAction());
|
|
643
|
-
logger.info(`Initialized screenci project "${projectName}" in ${
|
|
1171
|
+
logger.info(`Initialized screenci project "${projectName}" in ${projectDir}/`);
|
|
644
1172
|
logger.info('Files created:');
|
|
645
1173
|
logger.info(' screenci.config.ts');
|
|
646
1174
|
logger.info(' package.json');
|
|
@@ -649,149 +1177,401 @@ async function runInit(projectNameArg, localPackagePath) {
|
|
|
649
1177
|
logger.info(' videos/example.video.ts');
|
|
650
1178
|
logger.info(' .github/workflows/record.yml');
|
|
651
1179
|
logger.info('');
|
|
652
|
-
logger.info('
|
|
653
|
-
|
|
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
|
+
}
|
|
654
1264
|
logger.info('');
|
|
655
1265
|
logger.info('Next steps:');
|
|
656
1266
|
logger.info(` cd ${dirName}`);
|
|
657
|
-
|
|
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');
|
|
658
1277
|
}
|
|
659
1278
|
export async function main() {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
catch (err) {
|
|
690
|
-
if (!process.env.SCREENCI_SECRET) {
|
|
691
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
692
|
-
logger.error(`Failed to acquire secret: ${msg}`);
|
|
693
|
-
process.exit(1);
|
|
694
|
-
}
|
|
695
|
-
// Config import failed but SCREENCI_SECRET is already in env — continue
|
|
696
|
-
}
|
|
697
|
-
if (!process.env.SCREENCI_SECRET) {
|
|
698
|
-
logger.info('No SCREENCI_SECRET in .env file, opening browser for authentication...');
|
|
699
|
-
const devPort = process.env.DEV_PORT;
|
|
700
|
-
const appUrl = process.env.SCREENCI_APP_URL ??
|
|
701
|
-
(devPort
|
|
702
|
-
? `http://localhost:${devPort}`
|
|
703
|
-
: 'https://app.screenci.com');
|
|
704
|
-
const secret = await performBrowserLogin(appUrl);
|
|
705
|
-
const savePath = envFilePath ?? resolve(dirname(resolvedConfigForSecret), '.env');
|
|
706
|
-
await writeFile(savePath, `SCREENCI_SECRET=${secret}\n`);
|
|
707
|
-
process.env.SCREENCI_SECRET = secret;
|
|
708
|
-
logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
if (useContainer) {
|
|
713
|
-
await runWithContainer(otherArgs, configPath, imageTag, verbose);
|
|
714
|
-
}
|
|
715
|
-
else {
|
|
716
|
-
await run(command, otherArgs, configPath);
|
|
717
|
-
}
|
|
718
|
-
// Upload only from the host, not from inside the container
|
|
719
|
-
if (process.env.SCREENCI_IN_CONTAINER === 'true')
|
|
720
|
-
break;
|
|
721
|
-
// After recording, upload results to API if configured
|
|
722
|
-
const resolvedConfigPath = findScreenCIConfig(configPath);
|
|
723
|
-
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;
|
|
724
1308
|
try {
|
|
725
|
-
const configModule = await import(
|
|
1309
|
+
const configModule = await import(resolvedConfigForSecret);
|
|
726
1310
|
const screenciConfig = configModule.default;
|
|
727
|
-
|
|
728
|
-
|
|
1311
|
+
envFilePath = screenciConfig.envFile
|
|
1312
|
+
? resolve(dirname(resolvedConfigForSecret), screenciConfig.envFile)
|
|
1313
|
+
: null;
|
|
1314
|
+
if (envFilePath) {
|
|
729
1315
|
try {
|
|
730
1316
|
process.loadEnvFile(envFilePath);
|
|
731
1317
|
}
|
|
732
|
-
catch
|
|
733
|
-
|
|
1318
|
+
catch {
|
|
1319
|
+
// env file may not exist yet
|
|
734
1320
|
}
|
|
735
1321
|
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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);
|
|
748
1361
|
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
const projectId = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret);
|
|
752
|
-
if (projectId !== null) {
|
|
753
|
-
logger.info('');
|
|
754
|
-
logger.info('Recording finished, results available at:');
|
|
755
|
-
logger.info(`${appUrl}/project/${projectId}`);
|
|
1362
|
+
catch (err) {
|
|
1363
|
+
logger.warn(`Failed to load env file ${envFilePath}:`, err);
|
|
756
1364
|
}
|
|
757
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);
|
|
1378
|
+
}
|
|
758
1379
|
catch (err) {
|
|
759
|
-
|
|
1380
|
+
if (isUploadCancelledError(err)) {
|
|
1381
|
+
process.exit(130);
|
|
1382
|
+
}
|
|
1383
|
+
throw err;
|
|
760
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, '..');
|
|
761
1460
|
}
|
|
762
|
-
|
|
1461
|
+
await runInit(name, localPackagePath, options['verbose'] ?? false);
|
|
763
1462
|
}
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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);
|
|
773
1473
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
// cli.ts is at package root; dist/cli.js is one level down
|
|
781
|
-
localPackagePath = existsSync(resolve(cliDir, 'package.json'))
|
|
782
|
-
? cliDir
|
|
783
|
-
: resolve(cliDir, '..');
|
|
784
|
-
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);
|
|
785
1480
|
}
|
|
786
|
-
|
|
1481
|
+
logger.error(`Error: ${err.message}`);
|
|
1482
|
+
process.exit(1);
|
|
787
1483
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
logger.error(
|
|
1484
|
+
if (err.code === 'commander.help' ||
|
|
1485
|
+
err.code === 'commander.helpDisplayed') {
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
logger.error(`Error: ${err.message}`);
|
|
793
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
|
+
}
|
|
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
|
+
}
|
|
794
1573
|
}
|
|
1574
|
+
return { configPath, otherArgs };
|
|
795
1575
|
}
|
|
796
1576
|
function validateArgs(args) {
|
|
797
1577
|
const disallowedFlags = ['--fully-parallel', '--workers', '-j', '--retries'];
|
|
@@ -812,28 +1592,22 @@ function validateArgs(args) {
|
|
|
812
1592
|
}
|
|
813
1593
|
}
|
|
814
1594
|
}
|
|
815
|
-
function spawnInherited(cmd, args) {
|
|
816
|
-
const child = spawn(cmd, args, { stdio: 'inherit' });
|
|
817
|
-
const
|
|
818
|
-
logger.info(`Received ${signal}, stopping...`);
|
|
819
|
-
if (!child.killed) {
|
|
820
|
-
child.kill(signal);
|
|
821
|
-
}
|
|
822
|
-
const forceKill = setTimeout(() => {
|
|
823
|
-
if (child.exitCode === null) {
|
|
824
|
-
logger.info('Forcing kill after timeout...');
|
|
825
|
-
child.kill('SIGKILL');
|
|
826
|
-
}
|
|
827
|
-
}, 3000);
|
|
828
|
-
forceKill.unref();
|
|
829
|
-
};
|
|
830
|
-
process.on('SIGINT', forwardSignal);
|
|
831
|
-
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);
|
|
832
1598
|
return new Promise((resolve, reject) => {
|
|
833
|
-
child.on('close', (code) => {
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
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) {
|
|
837
1611
|
resolve();
|
|
838
1612
|
}
|
|
839
1613
|
else {
|
|
@@ -841,44 +1615,55 @@ function spawnInherited(cmd, args) {
|
|
|
841
1615
|
}
|
|
842
1616
|
});
|
|
843
1617
|
child.on('error', (err) => {
|
|
844
|
-
|
|
845
|
-
process.off('SIGTERM', forwardSignal);
|
|
1618
|
+
childSignals.cleanup();
|
|
846
1619
|
reject(err);
|
|
847
1620
|
});
|
|
848
1621
|
});
|
|
849
1622
|
}
|
|
850
|
-
export function detectContainerRuntime() {
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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);
|
|
856
1633
|
}
|
|
857
1634
|
logger.error('Error: Neither podman nor docker found.');
|
|
858
1635
|
logger.error('Please install podman (recommended) or docker to use screenci record.');
|
|
859
|
-
logger.error(
|
|
860
|
-
logger.error(' docker: https://docs.docker.com/get-docker/');
|
|
1636
|
+
logger.error(prerequisitesMessage());
|
|
861
1637
|
process.exit(1);
|
|
862
1638
|
}
|
|
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
|
+
}
|
|
863
1648
|
async function buildImage(cmd, args, label, verbose) {
|
|
864
1649
|
if (verbose) {
|
|
865
1650
|
await spawnInherited(cmd, args);
|
|
866
1651
|
return;
|
|
867
1652
|
}
|
|
868
|
-
|
|
1653
|
+
const spinner = ora(label).start();
|
|
869
1654
|
try {
|
|
870
1655
|
await spawnSilent(cmd, args);
|
|
871
|
-
|
|
1656
|
+
spinner.succeed(label);
|
|
872
1657
|
}
|
|
873
1658
|
catch (err) {
|
|
874
|
-
|
|
1659
|
+
spinner.fail(label);
|
|
875
1660
|
const msg = err instanceof Error ? err.message : String(err);
|
|
876
1661
|
logger.error(msg);
|
|
877
1662
|
logger.error('Run again with --verbose to see the full build output');
|
|
878
1663
|
process.exit(1);
|
|
879
1664
|
}
|
|
880
1665
|
}
|
|
881
|
-
async function runWithContainer(additionalArgs, customConfigPath, imageTag, verbose = false) {
|
|
1666
|
+
async function runWithContainer(additionalArgs, customConfigPath, imageTag, verbose = false, forcedRuntime) {
|
|
882
1667
|
const configPath = findScreenCIConfig(customConfigPath);
|
|
883
1668
|
if (!configPath) {
|
|
884
1669
|
const errorMsg = customConfigPath
|
|
@@ -899,7 +1684,7 @@ async function runWithContainer(additionalArgs, customConfigPath, imageTag, verb
|
|
|
899
1684
|
logger.error('Error: Could not find repository root (.git or pnpm-workspace.yaml)');
|
|
900
1685
|
process.exit(1);
|
|
901
1686
|
}
|
|
902
|
-
const containerRuntime = detectContainerRuntime();
|
|
1687
|
+
const containerRuntime = detectContainerRuntime(forcedRuntime);
|
|
903
1688
|
const ghcrImage = 'ghcr.io/screenci/record:latest';
|
|
904
1689
|
const dockerfileVersion = parseDockerfileVersion(dockerfilePath);
|
|
905
1690
|
if (process.env['SCREENCI_LOCAL_IMAGE']) {
|
|
@@ -912,13 +1697,15 @@ async function runWithContainer(additionalArgs, customConfigPath, imageTag, verb
|
|
|
912
1697
|
}).status === 0;
|
|
913
1698
|
logger.info(`Using image tag ${imageTag} instead of the version ${dockerfileVersion} from Dockerfile`);
|
|
914
1699
|
if (!imageExists) {
|
|
915
|
-
await buildImage(containerRuntime, ['pull', remoteImage], 'Pulling image', verbose);
|
|
1700
|
+
await buildImage(containerRuntime, ['pull', remoteImage], 'Pulling screenci image', verbose);
|
|
916
1701
|
}
|
|
917
1702
|
await spawnSilent(containerRuntime, ['tag', remoteImage, ghcrImage]);
|
|
918
1703
|
}
|
|
919
1704
|
else {
|
|
920
1705
|
const cliDir = dirname(fileURLToPath(import.meta.url));
|
|
921
|
-
const screenciPackageRoot = resolve(cliDir, '
|
|
1706
|
+
const screenciPackageRoot = existsSync(resolve(cliDir, 'package.json'))
|
|
1707
|
+
? cliDir
|
|
1708
|
+
: resolve(cliDir, '..');
|
|
922
1709
|
const screenciDockerfilePath = resolve(cliDir, 'Dockerfile');
|
|
923
1710
|
if (verbose) {
|
|
924
1711
|
await spawnInherited(containerRuntime, [
|
|
@@ -928,18 +1715,11 @@ async function runWithContainer(additionalArgs, customConfigPath, imageTag, verb
|
|
|
928
1715
|
'-t',
|
|
929
1716
|
ghcrImage,
|
|
930
1717
|
screenciPackageRoot,
|
|
931
|
-
]);
|
|
932
|
-
await spawnInherited(containerRuntime, [
|
|
933
|
-
'build',
|
|
934
|
-
'-f',
|
|
935
|
-
dockerfilePath,
|
|
936
|
-
'-t',
|
|
937
|
-
'screenci',
|
|
938
|
-
configDir,
|
|
939
|
-
]);
|
|
1718
|
+
], undefined, 'building screenci image');
|
|
1719
|
+
await spawnInherited(containerRuntime, ['build', '-f', dockerfilePath, '-t', 'screenci', configDir], undefined, 'building project image');
|
|
940
1720
|
}
|
|
941
1721
|
else {
|
|
942
|
-
|
|
1722
|
+
const spinner = ora('Building screenci image').start();
|
|
943
1723
|
try {
|
|
944
1724
|
await spawnSilent(containerRuntime, [
|
|
945
1725
|
'build',
|
|
@@ -957,10 +1737,10 @@ async function runWithContainer(additionalArgs, customConfigPath, imageTag, verb
|
|
|
957
1737
|
'screenci',
|
|
958
1738
|
configDir,
|
|
959
1739
|
]);
|
|
960
|
-
|
|
1740
|
+
spinner.succeed('Building screenci image');
|
|
961
1741
|
}
|
|
962
1742
|
catch (err) {
|
|
963
|
-
|
|
1743
|
+
spinner.fail('Building screenci image');
|
|
964
1744
|
const msg = err instanceof Error ? err.message : String(err);
|
|
965
1745
|
logger.error(msg);
|
|
966
1746
|
logger.error('Run again with --verbose to see the full build output');
|
|
@@ -969,7 +1749,7 @@ async function runWithContainer(additionalArgs, customConfigPath, imageTag, verb
|
|
|
969
1749
|
}
|
|
970
1750
|
}
|
|
971
1751
|
if (imageTag !== undefined || process.env['SCREENCI_LOCAL_IMAGE']) {
|
|
972
|
-
await buildImage(containerRuntime, ['build', '-f', dockerfilePath, '-t', 'screenci', configDir], 'Building image', verbose);
|
|
1752
|
+
await buildImage(containerRuntime, ['build', '-f', dockerfilePath, '-t', 'screenci', configDir], 'Building project image', verbose);
|
|
973
1753
|
}
|
|
974
1754
|
clearDirectory(resolve(configDir, '.screenci'));
|
|
975
1755
|
const secret = process.env['SCREENCI_SECRET'];
|
|
@@ -986,6 +1766,8 @@ async function runWithContainer(additionalArgs, customConfigPath, imageTag, verb
|
|
|
986
1766
|
'SCREENCI_RECORD=true',
|
|
987
1767
|
'-e',
|
|
988
1768
|
`SCREENCI_SECRET=${secret}`,
|
|
1769
|
+
'-e',
|
|
1770
|
+
'SCREENCI_SIGNAL_LOGGING=silent',
|
|
989
1771
|
'-v',
|
|
990
1772
|
`${configDir}/.screenci:/app/.screenci`,
|
|
991
1773
|
'-v',
|
|
@@ -1016,7 +1798,13 @@ async function run(command, additionalArgs, customConfigPath) {
|
|
|
1016
1798
|
// For dev command: use --ui unless --headed is specified
|
|
1017
1799
|
const isHeaded = additionalArgs.includes('--headed');
|
|
1018
1800
|
const shouldUseUI = command === 'dev' && !isHeaded;
|
|
1019
|
-
const mode = command === 'dev'
|
|
1801
|
+
const mode = command === 'dev'
|
|
1802
|
+
? isHeaded
|
|
1803
|
+
? 'headed mode'
|
|
1804
|
+
: 'UI mode'
|
|
1805
|
+
: command === 'test'
|
|
1806
|
+
? 'tests'
|
|
1807
|
+
: 'recorder';
|
|
1020
1808
|
if (process.env.SCREENCI_IN_CONTAINER !== 'true') {
|
|
1021
1809
|
logger.info(`Running ScreenCI ${mode} with npx...`);
|
|
1022
1810
|
logger.info(`Using config: ${configPath}`);
|
|
@@ -1037,29 +1825,19 @@ async function run(command, additionalArgs, customConfigPath) {
|
|
|
1037
1825
|
...(command === 'record' ? { SCREENCI_RECORD: 'true' } : {}),
|
|
1038
1826
|
},
|
|
1039
1827
|
});
|
|
1040
|
-
const
|
|
1041
|
-
logger.info(`Received ${signal}, stopping recording...`);
|
|
1042
|
-
if (!child.killed) {
|
|
1043
|
-
child.kill(signal);
|
|
1044
|
-
}
|
|
1045
|
-
// Force-kill after 3 s if the child hasn't actually exited yet.
|
|
1046
|
-
// child.killed becomes true as soon as we send the signal, so we check
|
|
1047
|
-
// child.exitCode instead — it stays null until the process truly exits.
|
|
1048
|
-
// unref() so the timer doesn't keep the process alive on its own.
|
|
1049
|
-
const forceKill = setTimeout(() => {
|
|
1050
|
-
if (child.exitCode === null) {
|
|
1051
|
-
logger.info('Forcing kill after timeout...');
|
|
1052
|
-
child.kill('SIGKILL');
|
|
1053
|
-
}
|
|
1054
|
-
}, 3000);
|
|
1055
|
-
forceKill.unref();
|
|
1056
|
-
};
|
|
1057
|
-
process.on('SIGINT', forwardSignal);
|
|
1058
|
-
process.on('SIGTERM', forwardSignal);
|
|
1828
|
+
const childSignals = forwardChildSignals(child, `screenci ${command}`);
|
|
1059
1829
|
return new Promise((resolve, reject) => {
|
|
1060
|
-
child.on('close', (code) => {
|
|
1061
|
-
|
|
1062
|
-
|
|
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
|
+
}
|
|
1063
1841
|
if (code === 0) {
|
|
1064
1842
|
resolve();
|
|
1065
1843
|
}
|
|
@@ -1068,6 +1846,7 @@ async function run(command, additionalArgs, customConfigPath) {
|
|
|
1068
1846
|
}
|
|
1069
1847
|
});
|
|
1070
1848
|
child.on('error', (err) => {
|
|
1849
|
+
childSignals.cleanup();
|
|
1071
1850
|
reject(err);
|
|
1072
1851
|
});
|
|
1073
1852
|
});
|