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