screenci 0.0.44 → 0.0.45
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/LICENCE +21 -0
- package/README.md +74 -195
- package/dist/cli.d.ts +20 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +678 -463
- package/dist/cli.js.map +1 -1
- package/dist/docs/manifest.d.ts +521 -0
- package/dist/docs/manifest.d.ts.map +1 -0
- package/dist/docs/manifest.js +237 -0
- package/dist/docs/manifest.js.map +1 -0
- package/dist/docs/video-sources/camera-and-zooming.video.d.ts +2 -0
- package/dist/docs/video-sources/camera-and-zooming.video.d.ts.map +1 -0
- package/dist/docs/video-sources/camera-and-zooming.video.js +37 -0
- package/dist/docs/video-sources/camera-and-zooming.video.js.map +1 -0
- package/dist/docs/video-sources/ci-setup.video.d.ts +2 -0
- package/dist/docs/video-sources/ci-setup.video.d.ts.map +1 -0
- package/dist/docs/video-sources/ci-setup.video.js +37 -0
- package/dist/docs/video-sources/ci-setup.video.js.map +1 -0
- package/dist/docs/video-sources/cli.video.d.ts +2 -0
- package/dist/docs/video-sources/cli.video.d.ts.map +1 -0
- package/dist/docs/video-sources/cli.video.js +37 -0
- package/dist/docs/video-sources/cli.video.js.map +1 -0
- package/dist/docs/video-sources/docs-shared.d.ts +5 -0
- package/dist/docs/video-sources/docs-shared.d.ts.map +1 -0
- package/dist/docs/video-sources/docs-shared.js +14 -0
- package/dist/docs/video-sources/docs-shared.js.map +1 -0
- package/dist/docs/video-sources/generating-videos.video.d.ts +2 -0
- package/dist/docs/video-sources/generating-videos.video.d.ts.map +1 -0
- package/dist/docs/video-sources/generating-videos.video.js +37 -0
- package/dist/docs/video-sources/generating-videos.video.js.map +1 -0
- package/dist/docs/video-sources/installation.video.d.ts +2 -0
- package/dist/docs/video-sources/installation.video.d.ts.map +1 -0
- package/dist/docs/video-sources/installation.video.js +26 -0
- package/dist/docs/video-sources/installation.video.js.map +1 -0
- package/dist/docs/video-sources/narration-and-localization.video.d.ts +2 -0
- package/dist/docs/video-sources/narration-and-localization.video.d.ts.map +1 -0
- package/dist/docs/video-sources/narration-and-localization.video.js +40 -0
- package/dist/docs/video-sources/narration-and-localization.video.js.map +1 -0
- package/dist/docs/video-sources/public-urls-and-embeds.video.d.ts +2 -0
- package/dist/docs/video-sources/public-urls-and-embeds.video.d.ts.map +1 -0
- package/dist/docs/video-sources/public-urls-and-embeds.video.js +37 -0
- package/dist/docs/video-sources/public-urls-and-embeds.video.js.map +1 -0
- package/dist/docs/video-sources/record-and-publish.video.d.ts +2 -0
- package/dist/docs/video-sources/record-and-publish.video.d.ts.map +1 -0
- package/dist/docs/video-sources/record-and-publish.video.js +37 -0
- package/dist/docs/video-sources/record-and-publish.video.js.map +1 -0
- package/dist/docs/video-sources/run-and-debug-videos.video.d.ts +2 -0
- package/dist/docs/video-sources/run-and-debug-videos.video.d.ts.map +1 -0
- package/dist/docs/video-sources/run-and-debug-videos.video.js +37 -0
- package/dist/docs/video-sources/run-and-debug-videos.video.js.map +1 -0
- package/dist/docs/video-sources/write-video-scripts.video.d.ts +2 -0
- package/dist/docs/video-sources/write-video-scripts.video.d.ts.map +1 -0
- package/dist/docs/video-sources/write-video-scripts.video.js +40 -0
- package/dist/docs/video-sources/write-video-scripts.video.js.map +1 -0
- package/dist/docs/videos.d.ts +56 -0
- package/dist/docs/videos.d.ts.map +1 -0
- package/dist/docs/videos.js +37 -0
- package/dist/docs/videos.js.map +1 -0
- package/dist/index.d.ts +4 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -6
- package/dist/index.js.map +1 -1
- package/dist/src/asset.d.ts.map +1 -1
- package/dist/src/asset.js +3 -4
- package/dist/src/asset.js.map +1 -1
- package/dist/src/autoZoom.d.ts +3 -33
- package/dist/src/autoZoom.d.ts.map +1 -1
- package/dist/src/autoZoom.js +46 -51
- package/dist/src/autoZoom.js.map +1 -1
- package/dist/src/changeFocus.d.ts.map +1 -1
- package/dist/src/changeFocus.js +3 -2
- package/dist/src/changeFocus.js.map +1 -1
- package/dist/src/config.d.ts +5 -7
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +41 -71
- package/dist/src/config.js.map +1 -1
- package/dist/src/cue.d.ts +13 -26
- package/dist/src/cue.d.ts.map +1 -1
- package/dist/src/cue.js +61 -97
- package/dist/src/cue.js.map +1 -1
- package/dist/src/customVoiceRef.d.ts +3 -0
- package/dist/src/customVoiceRef.d.ts.map +1 -0
- package/dist/src/customVoiceRef.js +7 -0
- package/dist/src/customVoiceRef.js.map +1 -0
- package/dist/src/defaults.d.ts +5 -5
- package/dist/src/defaults.d.ts.map +1 -1
- package/dist/src/defaults.js +11 -5
- package/dist/src/defaults.js.map +1 -1
- package/dist/src/events.d.ts +7 -6
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +17 -0
- package/dist/src/events.js.map +1 -1
- package/dist/src/fileSystemName.d.ts +2 -0
- package/dist/src/fileSystemName.d.ts.map +1 -0
- package/dist/src/fileSystemName.js +44 -0
- package/dist/src/fileSystemName.js.map +1 -0
- package/dist/src/hide.d.ts +1 -1
- package/dist/src/hide.d.ts.map +1 -1
- package/dist/src/hide.js +12 -15
- package/dist/src/hide.js.map +1 -1
- package/dist/src/instrument.d.ts +1 -0
- package/dist/src/instrument.d.ts.map +1 -1
- package/dist/src/instrument.js +29 -10
- package/dist/src/instrument.js.map +1 -1
- package/dist/src/manualZoom.d.ts.map +1 -1
- package/dist/src/manualZoom.js +13 -13
- package/dist/src/manualZoom.js.map +1 -1
- package/dist/src/mouse.d.ts.map +1 -1
- package/dist/src/mouse.js +4 -1
- package/dist/src/mouse.js.map +1 -1
- package/dist/src/recording.d.ts +2 -2
- package/dist/src/recording.d.ts.map +1 -1
- package/dist/src/runtimeContext.d.ts +81 -0
- package/dist/src/runtimeContext.d.ts.map +1 -0
- package/dist/src/runtimeContext.js +95 -0
- package/dist/src/runtimeContext.js.map +1 -0
- package/dist/src/runtimeMode.d.ts +8 -0
- package/dist/src/runtimeMode.d.ts.map +1 -0
- package/dist/src/runtimeMode.js +22 -0
- package/dist/src/runtimeMode.js.map +1 -0
- package/dist/src/titleValidation.d.ts +3 -0
- package/dist/src/titleValidation.d.ts.map +1 -0
- package/dist/src/titleValidation.js +19 -0
- package/dist/src/titleValidation.js.map +1 -0
- package/dist/src/types.d.ts +27 -15
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js.map +1 -1
- package/dist/src/video.d.ts +6 -1
- package/dist/src/video.d.ts.map +1 -1
- package/dist/src/video.js +94 -46
- package/dist/src/video.js.map +1 -1
- package/dist/src/voices.d.ts +8 -11
- package/dist/src/voices.d.ts.map +1 -1
- package/dist/src/voices.js +13 -8
- package/dist/src/voices.js.map +1 -1
- package/dist/src/zoom.d.ts +1 -1
- package/dist/src/zoom.d.ts.map +1 -1
- package/dist/test-fixtures/record-all-or-nothing.config.d.ts +8 -0
- package/dist/test-fixtures/record-all-or-nothing.config.d.ts.map +1 -0
- package/dist/test-fixtures/record-all-or-nothing.config.js +7 -0
- package/dist/test-fixtures/record-all-or-nothing.config.js.map +1 -0
- package/dist/test-fixtures/record-upload-all-or-nothing.config.d.ts +8 -0
- package/dist/test-fixtures/record-upload-all-or-nothing.config.d.ts.map +1 -0
- package/dist/test-fixtures/record-upload-all-or-nothing.config.js +7 -0
- package/dist/test-fixtures/record-upload-all-or-nothing.config.js.map +1 -0
- package/dist/test-fixtures/record-upload.config.d.ts +5 -0
- package/dist/test-fixtures/record-upload.config.d.ts.map +1 -0
- package/dist/test-fixtures/record-upload.config.js +4 -0
- package/dist/test-fixtures/record-upload.config.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -2
- package/skills/screenci/SKILL.md +6 -1
- package/skills/screenci/references/init.md +10 -12
- package/dist/src/reporter.d.ts +0 -9
- package/dist/src/reporter.d.ts.map +0 -1
- package/dist/src/reporter.js +0 -50
- package/dist/src/reporter.js.map +0 -1
- package/dist/src/sanitize.d.ts +0 -5
- package/dist/src/sanitize.d.ts.map +0 -1
- package/dist/src/sanitize.js +0 -11
- package/dist/src/sanitize.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -4,13 +4,109 @@ import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync } from 'fs';
|
|
|
4
4
|
import { createHash } from 'crypto';
|
|
5
5
|
import { createServer } from 'http';
|
|
6
6
|
import { appendFile, mkdir, readdir, readFile, stat, writeFile, } from 'fs/promises';
|
|
7
|
-
import { dirname, relative as pathRelative, resolve } from 'path';
|
|
7
|
+
import { basename, dirname, relative as pathRelative, resolve } from 'path';
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
9
9
|
import { Command, CommanderError } from 'commander';
|
|
10
|
-
import { input
|
|
10
|
+
import { input } from '@inquirer/prompts';
|
|
11
11
|
import ora from 'ora';
|
|
12
12
|
import pc from 'picocolors';
|
|
13
13
|
import { logger } from './src/logger.js';
|
|
14
|
+
import { SCREENCI_DISABLE_RECORDING_TIMINGS_ENV, SCREENCI_MOCK_RECORD_ENV, } from './src/runtimeMode.js';
|
|
15
|
+
import { DEFAULT_RECORD_UPLOAD_POLICY } from './src/defaults.js';
|
|
16
|
+
import { findDuplicateTitles, formatDuplicateTitlesMessage, } from './src/titleValidation.js';
|
|
17
|
+
const SCREENCI_MOCK_RECORD_DOCS_URL = 'https://screenci.com/docs/reference/cli/#--mock-record';
|
|
18
|
+
export function collectPlaywrightListTitles(suites) {
|
|
19
|
+
const titles = [];
|
|
20
|
+
const visitSuite = (suite) => {
|
|
21
|
+
for (const spec of suite.specs ?? []) {
|
|
22
|
+
titles.push(spec.title);
|
|
23
|
+
}
|
|
24
|
+
for (const child of suite.suites ?? []) {
|
|
25
|
+
visitSuite(child);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
for (const suite of suites) {
|
|
29
|
+
visitSuite(suite);
|
|
30
|
+
}
|
|
31
|
+
return titles;
|
|
32
|
+
}
|
|
33
|
+
function parsePlaywrightListReport(stdout) {
|
|
34
|
+
return JSON.parse(stdout);
|
|
35
|
+
}
|
|
36
|
+
async function collectDiscoveredTestTitles(configPath, additionalArgs, env) {
|
|
37
|
+
const listArgs = [
|
|
38
|
+
'test',
|
|
39
|
+
'--config',
|
|
40
|
+
configPath,
|
|
41
|
+
...additionalArgs,
|
|
42
|
+
'--list',
|
|
43
|
+
'--reporter=json',
|
|
44
|
+
];
|
|
45
|
+
const spawnSpec = resolveSpawnSpec('playwright', listArgs);
|
|
46
|
+
return await new Promise((resolve, reject) => {
|
|
47
|
+
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
48
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
49
|
+
...(spawnSpec.shell !== undefined ? { shell: spawnSpec.shell } : {}),
|
|
50
|
+
env,
|
|
51
|
+
});
|
|
52
|
+
const childSignals = forwardChildSignals(child, 'screenci title validation', {
|
|
53
|
+
killTree: process.platform !== 'win32',
|
|
54
|
+
exitParentOnForward: true,
|
|
55
|
+
});
|
|
56
|
+
let stdout = '';
|
|
57
|
+
let stderr = '';
|
|
58
|
+
child.stdout?.setEncoding?.('utf8');
|
|
59
|
+
child.stderr?.setEncoding?.('utf8');
|
|
60
|
+
child.stdout?.on('data', (chunk) => {
|
|
61
|
+
stdout += chunk;
|
|
62
|
+
});
|
|
63
|
+
child.stderr?.on('data', (chunk) => {
|
|
64
|
+
stderr += chunk;
|
|
65
|
+
});
|
|
66
|
+
child.on('close', (code, signal) => {
|
|
67
|
+
const forwardedSignal = childSignals.getForwardedSignal();
|
|
68
|
+
childSignals.cleanup();
|
|
69
|
+
if (forwardedSignal) {
|
|
70
|
+
process.kill(process.pid, forwardedSignal);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (signal) {
|
|
74
|
+
process.kill(process.pid, signal);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (code !== 0) {
|
|
78
|
+
if (stderr.trim() === '' && stdout.trim() === '') {
|
|
79
|
+
resolve([]);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
reject(new Error(stderr.trim() || stdout.trim() || 'Playwright test discovery failed'));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
if (stdout.trim() === '') {
|
|
87
|
+
resolve([]);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const report = parsePlaywrightListReport(stdout);
|
|
91
|
+
resolve(collectPlaywrightListTitles(report.suites ?? []));
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
reject(error);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
child.on('error', (err) => {
|
|
98
|
+
childSignals.cleanup();
|
|
99
|
+
reject(err);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
async function validateUniqueDiscoveredTestTitles(configPath, additionalArgs, env) {
|
|
104
|
+
const titles = await collectDiscoveredTestTitles(configPath, additionalArgs, env);
|
|
105
|
+
const duplicates = findDuplicateTitles(titles);
|
|
106
|
+
if (duplicates.length > 0) {
|
|
107
|
+
throw new Error(formatDuplicateTitlesMessage(duplicates));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
14
110
|
function resolveRecordingFileCandidates(filePath, configDir) {
|
|
15
111
|
return [
|
|
16
112
|
filePath,
|
|
@@ -50,11 +146,196 @@ class UploadCancelledError extends Error {
|
|
|
50
146
|
this.name = 'UploadCancelledError';
|
|
51
147
|
}
|
|
52
148
|
}
|
|
149
|
+
class PartialUploadError extends Error {
|
|
150
|
+
constructor(message = 'Not all recordings succeeded to upload.') {
|
|
151
|
+
super(message);
|
|
152
|
+
this.name = 'PartialUploadError';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
53
155
|
function isUploadCancelledError(err) {
|
|
54
156
|
return (err instanceof UploadCancelledError ||
|
|
55
157
|
(err instanceof Error &&
|
|
56
158
|
(err.name === 'AbortError' || err.name === 'UploadCancelledError')));
|
|
57
159
|
}
|
|
160
|
+
function isPartialUploadError(err) {
|
|
161
|
+
return err instanceof PartialUploadError;
|
|
162
|
+
}
|
|
163
|
+
function supportsInPlaceUploadUpdates(verbose) {
|
|
164
|
+
return !verbose && process.stdout.isTTY === true && !process.env.CI;
|
|
165
|
+
}
|
|
166
|
+
function formatUploadProgressLine(videoName, status) {
|
|
167
|
+
switch (status) {
|
|
168
|
+
case undefined:
|
|
169
|
+
return `${pc.cyan('...')} Uploading "${videoName}"`;
|
|
170
|
+
case 'success':
|
|
171
|
+
return `${pc.green('✔')} Uploaded "${videoName}"`;
|
|
172
|
+
case 'failure':
|
|
173
|
+
return `${pc.red('✖')} Failed to upload "${videoName}"`;
|
|
174
|
+
case 'cancelled':
|
|
175
|
+
return `${pc.yellow('!')} Cancelled "${videoName}"`;
|
|
176
|
+
default: {
|
|
177
|
+
const exhaustiveCheck = status;
|
|
178
|
+
return exhaustiveCheck;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function createUploadProgressReporter(videoNames, verbose) {
|
|
183
|
+
const useInPlaceUpdates = supportsInPlaceUploadUpdates(verbose);
|
|
184
|
+
if (!useInPlaceUpdates) {
|
|
185
|
+
if (videoNames.length > 1) {
|
|
186
|
+
logger.info(`Uploading ${videoNames.length} recordings in parallel...`);
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
complete(index, status) {
|
|
190
|
+
logger.info(formatUploadProgressLine(videoNames[index] ?? 'unknown', status));
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const statuses = new Array(videoNames.length);
|
|
195
|
+
let hasRendered = false;
|
|
196
|
+
const render = () => {
|
|
197
|
+
const renderedLines = videoNames.map((videoName, index) => formatUploadProgressLine(videoName, statuses[index]));
|
|
198
|
+
process.stdout.write(`${hasRendered && videoNames.length > 0 ? `\u001B[${videoNames.length}A` : ''}${renderedLines.map((line) => `\r\u001B[2K${line}`).join('\n')}\n`);
|
|
199
|
+
hasRendered = true;
|
|
200
|
+
};
|
|
201
|
+
render();
|
|
202
|
+
return {
|
|
203
|
+
complete(index, status) {
|
|
204
|
+
statuses[index] = status;
|
|
205
|
+
render();
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
async function loadUploadCandidate(screenciDir, entry, verbose) {
|
|
210
|
+
const dataJsonPath = resolve(screenciDir, entry, 'data.json');
|
|
211
|
+
if (!existsSync(dataJsonPath)) {
|
|
212
|
+
if (verbose)
|
|
213
|
+
logger.info(`Skipping "${entry}": no data.json found`);
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
let data;
|
|
217
|
+
try {
|
|
218
|
+
const raw = await readFile(dataJsonPath, 'utf-8');
|
|
219
|
+
data = JSON.parse(raw);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
logger.warn(`Failed to read ${dataJsonPath}, skipping`);
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
const videoName = data.metadata?.videoName ?? entry;
|
|
226
|
+
const preparedUploadAssets = await collectUploadAssets(data, resolve(screenciDir, '..'));
|
|
227
|
+
return {
|
|
228
|
+
entry,
|
|
229
|
+
videoName,
|
|
230
|
+
data: annotateRecordingDataWithAssetHashes(data, preparedUploadAssets),
|
|
231
|
+
preparedUploadAssets,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
async function uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, verbose, uploadAbort, progressReporter, progressIndex) {
|
|
235
|
+
const { entry, videoName, data, preparedUploadAssets } = candidate;
|
|
236
|
+
try {
|
|
237
|
+
uploadAbort.throwIfAborted();
|
|
238
|
+
const recordingPath = resolve(screenciDir, entry, 'recording.mp4');
|
|
239
|
+
const recordingHash = existsSync(recordingPath)
|
|
240
|
+
? await hashFile(recordingPath)
|
|
241
|
+
: undefined;
|
|
242
|
+
const startResponse = await fetch(`${apiUrl}/cli/upload/start`, {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: {
|
|
245
|
+
'Content-Type': 'application/json',
|
|
246
|
+
'X-ScreenCI-Secret': secret,
|
|
247
|
+
},
|
|
248
|
+
body: JSON.stringify({
|
|
249
|
+
projectName,
|
|
250
|
+
videoName,
|
|
251
|
+
data,
|
|
252
|
+
...(recordingHash !== undefined ? { recordingHash } : {}),
|
|
253
|
+
expectedAssets: preparedUploadAssets.map((asset) => ({
|
|
254
|
+
fileHash: asset.fileHash,
|
|
255
|
+
size: asset.size,
|
|
256
|
+
path: asset.path,
|
|
257
|
+
...(typeof asset.contentType === 'string'
|
|
258
|
+
? { contentType: asset.contentType }
|
|
259
|
+
: {}),
|
|
260
|
+
...(typeof asset.name === 'string' ? { name: asset.name } : {}),
|
|
261
|
+
})),
|
|
262
|
+
}),
|
|
263
|
+
signal: uploadAbort.signal,
|
|
264
|
+
});
|
|
265
|
+
if (!startResponse.ok) {
|
|
266
|
+
const text = await startResponse.text();
|
|
267
|
+
progressReporter.complete(progressIndex, 'failure');
|
|
268
|
+
return {
|
|
269
|
+
projectId: null,
|
|
270
|
+
hadFailure: true,
|
|
271
|
+
videoName,
|
|
272
|
+
failureMessage: formatUploadStartFailureMessage(videoName, startResponse.status, text, secret),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const { recordingId, projectId } = (await startResponse.json());
|
|
276
|
+
if (verbose) {
|
|
277
|
+
logger.info(`recordingId=${recordingId} projectId=${projectId}`);
|
|
278
|
+
logger.info(`assets=${preparedUploadAssets.length} recordingHash=${recordingHash ?? 'none'}`);
|
|
279
|
+
}
|
|
280
|
+
await uploadAssets(preparedUploadAssets, apiUrl, secret, recordingId, uploadAbort.signal, uploadAbort.throwIfAborted);
|
|
281
|
+
if (existsSync(recordingPath)) {
|
|
282
|
+
uploadAbort.throwIfAborted();
|
|
283
|
+
const fileStat = await stat(recordingPath);
|
|
284
|
+
if (verbose) {
|
|
285
|
+
logger.info(`Uploading recording.mp4 size=${(fileStat.size / 1024 / 1024).toFixed(1)}MB`);
|
|
286
|
+
}
|
|
287
|
+
const stream = createReadStream(recordingPath);
|
|
288
|
+
const abortStream = () => {
|
|
289
|
+
stream.destroy(new UploadCancelledError(`Upload cancelled for "${videoName}"`));
|
|
290
|
+
};
|
|
291
|
+
uploadAbort.signal.addEventListener('abort', abortStream, {
|
|
292
|
+
once: true,
|
|
293
|
+
});
|
|
294
|
+
try {
|
|
295
|
+
const recordingResponse = await fetch(`${apiUrl}/cli/upload/${recordingId}/recording`, {
|
|
296
|
+
method: 'PUT',
|
|
297
|
+
headers: {
|
|
298
|
+
'Content-Type': 'video/mp4',
|
|
299
|
+
'Content-Length': String(fileStat.size),
|
|
300
|
+
'X-ScreenCI-Secret': secret,
|
|
301
|
+
},
|
|
302
|
+
body: stream,
|
|
303
|
+
signal: uploadAbort.signal,
|
|
304
|
+
// @ts-expect-error Node.js fetch supports duplex for streaming
|
|
305
|
+
duplex: 'half',
|
|
306
|
+
});
|
|
307
|
+
if (!recordingResponse.ok) {
|
|
308
|
+
const text = await recordingResponse.text();
|
|
309
|
+
progressReporter.complete(progressIndex, 'failure');
|
|
310
|
+
return {
|
|
311
|
+
projectId,
|
|
312
|
+
hadFailure: true,
|
|
313
|
+
videoName,
|
|
314
|
+
failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
uploadAbort.signal.removeEventListener('abort', abortStream);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
progressReporter.complete(progressIndex, 'success');
|
|
323
|
+
return { projectId, hadFailure: false, videoName };
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
if (isUploadCancelledError(err)) {
|
|
327
|
+
progressReporter.complete(progressIndex, 'cancelled');
|
|
328
|
+
throw err;
|
|
329
|
+
}
|
|
330
|
+
progressReporter.complete(progressIndex, 'failure');
|
|
331
|
+
return {
|
|
332
|
+
projectId: null,
|
|
333
|
+
hadFailure: true,
|
|
334
|
+
videoName,
|
|
335
|
+
failureMessage: `Network error uploading "${videoName}": ${err instanceof Error ? err.message : String(err)}`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
58
339
|
export function attachUploadAbortStdinListener(input, onAbort) {
|
|
59
340
|
const handleStdinData = (chunk) => {
|
|
60
341
|
const bytes = typeof chunk === 'string'
|
|
@@ -160,9 +441,27 @@ function spawnSilent(cmd, args, cwd) {
|
|
|
160
441
|
});
|
|
161
442
|
});
|
|
162
443
|
}
|
|
163
|
-
function forwardChildSignals(child, activityLabel) {
|
|
444
|
+
function forwardChildSignals(child, activityLabel, options = {}) {
|
|
164
445
|
let forwardedSignal = null;
|
|
165
446
|
let forceKillTimer = null;
|
|
447
|
+
const killTree = options.killTree ?? false;
|
|
448
|
+
const exitParentOnForward = options.exitParentOnForward ?? false;
|
|
449
|
+
const killChild = (signal) => {
|
|
450
|
+
if (child.pid === undefined)
|
|
451
|
+
return;
|
|
452
|
+
if (killTree && process.platform !== 'win32') {
|
|
453
|
+
try {
|
|
454
|
+
process.kill(-child.pid, signal);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
// Fall back to direct child kill below.
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (!child.killed) {
|
|
462
|
+
child.kill(signal);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
166
465
|
const forwardSignal = (signal) => {
|
|
167
466
|
if (forwardedSignal !== null)
|
|
168
467
|
return;
|
|
@@ -170,31 +469,42 @@ function forwardChildSignals(child, activityLabel) {
|
|
|
170
469
|
if (process.env.SCREENCI_SIGNAL_LOGGING !== 'silent') {
|
|
171
470
|
logger.info(`Received ${signal}, stopping ${activityLabel}...`);
|
|
172
471
|
}
|
|
173
|
-
|
|
174
|
-
|
|
472
|
+
killChild(signal);
|
|
473
|
+
if (exitParentOnForward) {
|
|
474
|
+
cleanup();
|
|
475
|
+
process.exit(signal === 'SIGINT' ? 130 : 143);
|
|
175
476
|
}
|
|
176
477
|
forceKillTimer = setTimeout(() => {
|
|
177
478
|
if (child.exitCode === null) {
|
|
178
479
|
if (process.env.SCREENCI_SIGNAL_LOGGING !== 'silent') {
|
|
179
480
|
logger.info(`Forcing ${activityLabel} to stop after timeout...`);
|
|
180
481
|
}
|
|
181
|
-
|
|
482
|
+
killChild('SIGKILL');
|
|
483
|
+
process.exit(signal === 'SIGINT' ? 130 : 143);
|
|
182
484
|
}
|
|
183
485
|
}, 3000);
|
|
184
486
|
forceKillTimer.unref();
|
|
185
487
|
};
|
|
186
488
|
const handleSigint = () => forwardSignal('SIGINT');
|
|
187
489
|
const handleSigterm = () => forwardSignal('SIGTERM');
|
|
490
|
+
const cleanupStdinListener = attachUploadAbortStdinListener(process.stdin, (signal) => {
|
|
491
|
+
if (process.env.SCREENCI_SIGNAL_LOGGING !== 'silent') {
|
|
492
|
+
logger.info(`Received ${signal}, stopping ${activityLabel}...`);
|
|
493
|
+
}
|
|
494
|
+
forwardSignal(signal);
|
|
495
|
+
});
|
|
496
|
+
const cleanup = () => {
|
|
497
|
+
if (forceKillTimer !== null) {
|
|
498
|
+
clearTimeout(forceKillTimer);
|
|
499
|
+
}
|
|
500
|
+
process.off('SIGINT', handleSigint);
|
|
501
|
+
process.off('SIGTERM', handleSigterm);
|
|
502
|
+
cleanupStdinListener();
|
|
503
|
+
};
|
|
188
504
|
process.on('SIGINT', handleSigint);
|
|
189
505
|
process.on('SIGTERM', handleSigterm);
|
|
190
506
|
return {
|
|
191
|
-
cleanup
|
|
192
|
-
if (forceKillTimer !== null) {
|
|
193
|
-
clearTimeout(forceKillTimer);
|
|
194
|
-
}
|
|
195
|
-
process.off('SIGINT', handleSigint);
|
|
196
|
-
process.off('SIGTERM', handleSigterm);
|
|
197
|
-
},
|
|
507
|
+
cleanup,
|
|
198
508
|
getForwardedSignal: () => forwardedSignal,
|
|
199
509
|
};
|
|
200
510
|
}
|
|
@@ -219,46 +529,6 @@ function findScreenCIConfig(customPath) {
|
|
|
219
529
|
}
|
|
220
530
|
return null;
|
|
221
531
|
}
|
|
222
|
-
function findRepoRoot(startDir) {
|
|
223
|
-
let current = startDir;
|
|
224
|
-
while (true) {
|
|
225
|
-
if (existsSync(resolve(current, '.git')) ||
|
|
226
|
-
existsSync(resolve(current, 'pnpm-workspace.yaml')) ||
|
|
227
|
-
existsSync(resolve(current, 'package-lock.json')) ||
|
|
228
|
-
existsSync(resolve(current, 'yarn.lock'))) {
|
|
229
|
-
return current;
|
|
230
|
-
}
|
|
231
|
-
const parent = resolve(current, '..');
|
|
232
|
-
if (parent === current)
|
|
233
|
-
return null;
|
|
234
|
-
current = parent;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
async function findLatestEntry(screenciDir) {
|
|
238
|
-
let entries;
|
|
239
|
-
try {
|
|
240
|
-
entries = await readdir(screenciDir);
|
|
241
|
-
}
|
|
242
|
-
catch {
|
|
243
|
-
return null;
|
|
244
|
-
}
|
|
245
|
-
let latestEntry = null;
|
|
246
|
-
let latestMtime = 0;
|
|
247
|
-
for (const entry of entries) {
|
|
248
|
-
try {
|
|
249
|
-
const entryPath = resolve(screenciDir, entry);
|
|
250
|
-
const s = await stat(entryPath);
|
|
251
|
-
if (s.mtimeMs > latestMtime) {
|
|
252
|
-
latestMtime = s.mtimeMs;
|
|
253
|
-
latestEntry = entry;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
catch {
|
|
257
|
-
// skip unreadable entries
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
return latestEntry;
|
|
261
|
-
}
|
|
262
532
|
async function hashFile(filePath) {
|
|
263
533
|
return new Promise((resolveHash, reject) => {
|
|
264
534
|
const hash = createHash('sha256');
|
|
@@ -573,7 +843,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
573
843
|
}
|
|
574
844
|
}
|
|
575
845
|
}
|
|
576
|
-
async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specificEntry, verbose = false) {
|
|
846
|
+
export async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specificEntry, verbose = false) {
|
|
577
847
|
const uploadAbort = createUploadAbortController('upload');
|
|
578
848
|
let entries;
|
|
579
849
|
try {
|
|
@@ -581,135 +851,62 @@ async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specif
|
|
|
581
851
|
}
|
|
582
852
|
catch {
|
|
583
853
|
logger.warn('No .screenci directory found, skipping upload');
|
|
584
|
-
return
|
|
854
|
+
return {
|
|
855
|
+
projectId: null,
|
|
856
|
+
hadFailures: false,
|
|
857
|
+
failedVideoNames: [],
|
|
858
|
+
failedVideoMessages: [],
|
|
859
|
+
};
|
|
585
860
|
}
|
|
586
861
|
if (specificEntry !== undefined) {
|
|
587
862
|
entries = entries.filter((e) => e === specificEntry);
|
|
588
863
|
}
|
|
589
864
|
let firstProjectId = null;
|
|
590
865
|
try {
|
|
591
|
-
|
|
866
|
+
const candidates = (await Promise.all(entries.map(async (entry) => {
|
|
592
867
|
uploadAbort.throwIfAborted();
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
data = JSON.parse(raw);
|
|
603
|
-
}
|
|
604
|
-
catch {
|
|
605
|
-
logger.warn(`Failed to read ${dataJsonPath}, skipping`);
|
|
606
|
-
continue;
|
|
607
|
-
}
|
|
608
|
-
const videoName = data.metadata?.videoName ?? entry;
|
|
609
|
-
const preparedUploadAssets = await collectUploadAssets(data, resolve(screenciDir, '..'));
|
|
610
|
-
data = annotateRecordingDataWithAssetHashes(data, preparedUploadAssets);
|
|
611
|
-
const uploadSpinner = ora(`Uploading "${videoName}"`).start();
|
|
612
|
-
try {
|
|
613
|
-
uploadAbort.throwIfAborted();
|
|
614
|
-
const recordingPath = resolve(screenciDir, entry, 'recording.mp4');
|
|
615
|
-
const recordingHash = existsSync(recordingPath)
|
|
616
|
-
? await hashFile(recordingPath)
|
|
617
|
-
: undefined;
|
|
618
|
-
// Step 1: register upload and get recordingId
|
|
619
|
-
const startResponse = await fetch(`${apiUrl}/cli/upload/start`, {
|
|
620
|
-
method: 'POST',
|
|
621
|
-
headers: {
|
|
622
|
-
'Content-Type': 'application/json',
|
|
623
|
-
'X-ScreenCI-Secret': secret,
|
|
624
|
-
},
|
|
625
|
-
body: JSON.stringify({
|
|
626
|
-
projectName,
|
|
627
|
-
videoName,
|
|
628
|
-
data,
|
|
629
|
-
...(recordingHash !== undefined ? { recordingHash } : {}),
|
|
630
|
-
expectedAssets: preparedUploadAssets.map((asset) => ({
|
|
631
|
-
fileHash: asset.fileHash,
|
|
632
|
-
size: asset.size,
|
|
633
|
-
path: asset.path,
|
|
634
|
-
...(typeof asset.contentType === 'string'
|
|
635
|
-
? { contentType: asset.contentType }
|
|
636
|
-
: {}),
|
|
637
|
-
...(typeof asset.name === 'string' ? { name: asset.name } : {}),
|
|
638
|
-
})),
|
|
639
|
-
}),
|
|
640
|
-
signal: uploadAbort.signal,
|
|
641
|
-
});
|
|
642
|
-
if (!startResponse.ok) {
|
|
643
|
-
const text = await startResponse.text();
|
|
644
|
-
uploadSpinner.fail(`Failed to upload "${videoName}"`);
|
|
645
|
-
printUploadStartFailureMessage(videoName, startResponse.status, text, secret);
|
|
646
|
-
continue;
|
|
647
|
-
}
|
|
648
|
-
const { recordingId, projectId } = (await startResponse.json());
|
|
649
|
-
if (verbose) {
|
|
650
|
-
logger.info(`recordingId=${recordingId} projectId=${projectId}`);
|
|
651
|
-
logger.info(`assets=${preparedUploadAssets.length} recordingHash=${recordingHash ?? 'none'}`);
|
|
652
|
-
}
|
|
653
|
-
if (firstProjectId === null) {
|
|
654
|
-
firstProjectId = projectId;
|
|
655
|
-
}
|
|
656
|
-
// Step 1b: upload all referenced files via the shared asset flow
|
|
657
|
-
await uploadAssets(preparedUploadAssets, apiUrl, secret, recordingId, uploadAbort.signal, uploadAbort.throwIfAborted);
|
|
658
|
-
// Step 2: stream the recording video file (if it exists)
|
|
659
|
-
if (existsSync(recordingPath)) {
|
|
660
|
-
uploadAbort.throwIfAborted();
|
|
661
|
-
const fileStat = await stat(recordingPath);
|
|
662
|
-
if (verbose) {
|
|
663
|
-
logger.info(`Uploading recording.mp4 size=${(fileStat.size / 1024 / 1024).toFixed(1)}MB`);
|
|
664
|
-
}
|
|
665
|
-
const stream = createReadStream(recordingPath);
|
|
666
|
-
const abortStream = () => {
|
|
667
|
-
stream.destroy(new UploadCancelledError(`Upload cancelled for "${videoName}"`));
|
|
668
|
-
};
|
|
669
|
-
uploadAbort.signal.addEventListener('abort', abortStream, {
|
|
670
|
-
once: true,
|
|
671
|
-
});
|
|
672
|
-
try {
|
|
673
|
-
const recordingResponse = await fetch(`${apiUrl}/cli/upload/${recordingId}/recording`, {
|
|
674
|
-
method: 'PUT',
|
|
675
|
-
headers: {
|
|
676
|
-
'Content-Type': 'video/mp4',
|
|
677
|
-
'Content-Length': String(fileStat.size),
|
|
678
|
-
'X-ScreenCI-Secret': secret,
|
|
679
|
-
},
|
|
680
|
-
body: stream,
|
|
681
|
-
signal: uploadAbort.signal,
|
|
682
|
-
// @ts-expect-error Node.js fetch supports duplex for streaming
|
|
683
|
-
duplex: 'half',
|
|
684
|
-
});
|
|
685
|
-
if (!recordingResponse.ok) {
|
|
686
|
-
const text = await recordingResponse.text();
|
|
687
|
-
uploadSpinner.fail(`Failed to upload "${videoName}"`);
|
|
688
|
-
logger.warn(`Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`);
|
|
689
|
-
continue;
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
finally {
|
|
693
|
-
uploadAbort.signal.removeEventListener('abort', abortStream);
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
uploadSpinner.succeed(`Uploaded "${videoName}"`);
|
|
697
|
-
}
|
|
698
|
-
catch (err) {
|
|
699
|
-
if (isUploadCancelledError(err)) {
|
|
700
|
-
uploadSpinner.fail(`Cancelled "${videoName}"`);
|
|
701
|
-
throw err;
|
|
702
|
-
}
|
|
703
|
-
uploadSpinner.fail(`Error uploading "${videoName}"`);
|
|
704
|
-
logger.warn(`Network error uploading "${videoName}":`, err);
|
|
705
|
-
}
|
|
868
|
+
return await loadUploadCandidate(screenciDir, entry, verbose);
|
|
869
|
+
}))).filter((candidate) => candidate !== null);
|
|
870
|
+
if (candidates.length === 0) {
|
|
871
|
+
return {
|
|
872
|
+
projectId: null,
|
|
873
|
+
hadFailures: false,
|
|
874
|
+
failedVideoNames: [],
|
|
875
|
+
failedVideoMessages: [],
|
|
876
|
+
};
|
|
706
877
|
}
|
|
707
|
-
|
|
878
|
+
const progressReporter = createUploadProgressReporter(candidates.map((candidate) => candidate.videoName), verbose);
|
|
879
|
+
const results = await Promise.all(candidates.map(async (candidate, index) => await uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, verbose, uploadAbort, progressReporter, index)));
|
|
880
|
+
firstProjectId =
|
|
881
|
+
results.find((result) => result.projectId !== null)?.projectId ?? null;
|
|
882
|
+
const hadFailures = results.some((result) => result.hadFailure);
|
|
883
|
+
const failedVideoNames = results
|
|
884
|
+
.filter((result) => result.hadFailure)
|
|
885
|
+
.map((result) => result.videoName);
|
|
886
|
+
const failedVideoMessages = results.flatMap((result) => result.hadFailure && typeof result.failureMessage === 'string'
|
|
887
|
+
? [{ videoName: result.videoName, message: result.failureMessage }]
|
|
888
|
+
: []);
|
|
889
|
+
return {
|
|
890
|
+
projectId: firstProjectId,
|
|
891
|
+
hadFailures,
|
|
892
|
+
failedVideoNames,
|
|
893
|
+
failedVideoMessages,
|
|
894
|
+
};
|
|
708
895
|
}
|
|
709
896
|
finally {
|
|
710
897
|
uploadAbort.cleanup();
|
|
711
898
|
}
|
|
712
899
|
}
|
|
900
|
+
async function countCompletedRecordings(screenciDir) {
|
|
901
|
+
let entries;
|
|
902
|
+
try {
|
|
903
|
+
entries = await readdir(screenciDir);
|
|
904
|
+
}
|
|
905
|
+
catch {
|
|
906
|
+
return 0;
|
|
907
|
+
}
|
|
908
|
+
return entries.filter((entry) => existsSync(resolve(screenciDir, entry, 'data.json'))).length;
|
|
909
|
+
}
|
|
713
910
|
export function getDevBackendUrl() {
|
|
714
911
|
const devBackendPort = process.env.DEV_BACKEND_PORT;
|
|
715
912
|
return devBackendPort
|
|
@@ -728,40 +925,6 @@ async function writeGitHubProjectOutput(projectUrl) {
|
|
|
728
925
|
return;
|
|
729
926
|
await appendFile(githubOutput, `screenci_project_url=${projectUrl}\n`);
|
|
730
927
|
}
|
|
731
|
-
async function uploadLatest(configPath, verbose = false) {
|
|
732
|
-
const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
|
|
733
|
-
const apiUrl = getDevBackendUrl();
|
|
734
|
-
const secret = process.env.SCREENCI_SECRET;
|
|
735
|
-
if (!secret) {
|
|
736
|
-
logger.error('No secret configured. Set SCREENCI_SECRET in your .env file (get it from the API Key page in the dashboard).');
|
|
737
|
-
process.exit(1);
|
|
738
|
-
}
|
|
739
|
-
const configDir = dirname(resolvedConfigPath);
|
|
740
|
-
const screenciDir = resolve(configDir, '.screenci');
|
|
741
|
-
if (verbose) {
|
|
742
|
-
logger.info(`screenciDir=${screenciDir}`);
|
|
743
|
-
logger.info(`apiUrl=${apiUrl}`);
|
|
744
|
-
}
|
|
745
|
-
const appUrl = getDevFrontendUrl();
|
|
746
|
-
let projectId = null;
|
|
747
|
-
try {
|
|
748
|
-
projectId = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret, undefined, verbose);
|
|
749
|
-
}
|
|
750
|
-
catch (err) {
|
|
751
|
-
if (isUploadCancelledError(err)) {
|
|
752
|
-
process.exit(130);
|
|
753
|
-
}
|
|
754
|
-
throw err;
|
|
755
|
-
}
|
|
756
|
-
if (projectId !== null) {
|
|
757
|
-
const projectUrl = `${appUrl}/project/${projectId}`;
|
|
758
|
-
await writeGitHubProjectOutput(projectUrl);
|
|
759
|
-
logger.info('');
|
|
760
|
-
logger.info('Upload complete, rendering continues in the background.');
|
|
761
|
-
logger.info('Recording finished, results available at:');
|
|
762
|
-
logger.info(pc.cyan(projectUrl));
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
928
|
async function loadScreenCIConfigAndEnv(configPath) {
|
|
766
929
|
const resolvedConfigPath = findScreenCIConfig(configPath);
|
|
767
930
|
if (!resolvedConfigPath) {
|
|
@@ -815,6 +978,17 @@ async function loadEnvFileFromConfigSource(resolvedConfigPath, warnOnFailure) {
|
|
|
815
978
|
// the existing process env; Playwright will still load the config normally.
|
|
816
979
|
}
|
|
817
980
|
}
|
|
981
|
+
async function resolveConfiguredEnvFilePath(resolvedConfigPath) {
|
|
982
|
+
try {
|
|
983
|
+
const screenciConfig = await tryReadConfigFromSource(resolvedConfigPath);
|
|
984
|
+
if (!screenciConfig.envFile)
|
|
985
|
+
return undefined;
|
|
986
|
+
return resolve(dirname(resolvedConfigPath), screenciConfig.envFile);
|
|
987
|
+
}
|
|
988
|
+
catch {
|
|
989
|
+
return undefined;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
818
992
|
export function extractConfigStringLiteral(configSource, property) {
|
|
819
993
|
const singleQuoteMatch = configSource.match(new RegExp(property + "\\s*:\\s*'([^'\\n]+)'"));
|
|
820
994
|
if (singleQuoteMatch)
|
|
@@ -825,6 +999,21 @@ export function extractConfigStringLiteral(configSource, property) {
|
|
|
825
999
|
const templateLiteralMatch = configSource.match(new RegExp(property + '\\s*:\\s*`([^`\\n]+)`'));
|
|
826
1000
|
return templateLiteralMatch?.[1];
|
|
827
1001
|
}
|
|
1002
|
+
export function extractRecordUploadPolicyLiteral(configSource) {
|
|
1003
|
+
const singleQuoteMatch = configSource.match(/record\s*:\s*\{[\s\S]*?upload\s*:\s*'(passed-only|all-or-nothing)'/);
|
|
1004
|
+
if (singleQuoteMatch) {
|
|
1005
|
+
return singleQuoteMatch[1];
|
|
1006
|
+
}
|
|
1007
|
+
const doubleQuoteMatch = configSource.match(/record\s*:\s*\{[\s\S]*?upload\s*:\s*"(passed-only|all-or-nothing)"/);
|
|
1008
|
+
if (doubleQuoteMatch) {
|
|
1009
|
+
return doubleQuoteMatch[1];
|
|
1010
|
+
}
|
|
1011
|
+
const templateLiteralMatch = configSource.match(/record\s*:\s*\{[\s\S]*?upload\s*:\s*`(passed-only|all-or-nothing)`/);
|
|
1012
|
+
return templateLiteralMatch?.[1];
|
|
1013
|
+
}
|
|
1014
|
+
function resolveRecordUploadPolicy(config) {
|
|
1015
|
+
return config.record?.upload ?? DEFAULT_RECORD_UPLOAD_POLICY;
|
|
1016
|
+
}
|
|
828
1017
|
async function tryReadConfigFromSource(resolvedConfigPath) {
|
|
829
1018
|
const configSource = await readFile(resolvedConfigPath, 'utf-8');
|
|
830
1019
|
const projectName = extractConfigStringLiteral(configSource, 'projectName');
|
|
@@ -832,9 +1021,11 @@ async function tryReadConfigFromSource(resolvedConfigPath) {
|
|
|
832
1021
|
throw new Error('Could not determine projectName from screenci.config.ts without importing it.');
|
|
833
1022
|
}
|
|
834
1023
|
const envFile = extractConfigStringLiteral(configSource, 'envFile');
|
|
1024
|
+
const recordUpload = extractRecordUploadPolicyLiteral(configSource);
|
|
835
1025
|
return {
|
|
836
1026
|
projectName,
|
|
837
1027
|
...(envFile !== undefined ? { envFile } : {}),
|
|
1028
|
+
...(recordUpload !== undefined ? { record: { upload: recordUpload } } : {}),
|
|
838
1029
|
};
|
|
839
1030
|
}
|
|
840
1031
|
export function getConfigModuleSpecifier(resolvedConfigPath) {
|
|
@@ -850,11 +1041,17 @@ async function loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath) {
|
|
|
850
1041
|
return configModule.default;
|
|
851
1042
|
}
|
|
852
1043
|
catch (err) {
|
|
853
|
-
|
|
854
|
-
err.message.includes('Requiring @playwright/test second time')
|
|
1044
|
+
const hasPlaywrightCollision = err instanceof Error &&
|
|
1045
|
+
err.message.includes('Requiring @playwright/test second time');
|
|
1046
|
+
if (hasPlaywrightCollision) {
|
|
855
1047
|
logger.warn('Playwright was loaded from multiple module paths. Falling back to static config parsing for upload metadata.');
|
|
1048
|
+
}
|
|
1049
|
+
try {
|
|
856
1050
|
return (await tryReadConfigFromSource(resolvedConfigPath));
|
|
857
1051
|
}
|
|
1052
|
+
catch {
|
|
1053
|
+
// Preserve the original import error when static parsing cannot recover.
|
|
1054
|
+
}
|
|
858
1055
|
throw err;
|
|
859
1056
|
}
|
|
860
1057
|
}
|
|
@@ -917,7 +1114,7 @@ export default defineConfig({
|
|
|
917
1114
|
recordOptions: {
|
|
918
1115
|
aspectRatio: '16:9',
|
|
919
1116
|
quality: '1080p',
|
|
920
|
-
fps:
|
|
1117
|
+
fps: 60,
|
|
921
1118
|
},
|
|
922
1119
|
},
|
|
923
1120
|
projects: [
|
|
@@ -935,11 +1132,6 @@ function generatePackageJson(includePlaywrightCli = false, screenciDependency =
|
|
|
935
1132
|
}
|
|
936
1133
|
return (JSON.stringify({
|
|
937
1134
|
type: 'module',
|
|
938
|
-
scripts: {
|
|
939
|
-
record: 'screenci record',
|
|
940
|
-
retry: 'screenci retry',
|
|
941
|
-
test: 'screenci test',
|
|
942
|
-
},
|
|
943
1135
|
dependencies: {
|
|
944
1136
|
screenci: screenciDependency,
|
|
945
1137
|
'@playwright/test': '^1.59.0',
|
|
@@ -966,20 +1158,6 @@ async function readCurrentScreenciVersion() {
|
|
|
966
1158
|
}
|
|
967
1159
|
return 'latest';
|
|
968
1160
|
}
|
|
969
|
-
function generateTsconfig() {
|
|
970
|
-
return `${JSON.stringify({
|
|
971
|
-
compilerOptions: {
|
|
972
|
-
module: 'ESNext',
|
|
973
|
-
moduleResolution: 'bundler',
|
|
974
|
-
target: 'ESNext',
|
|
975
|
-
types: ['node'],
|
|
976
|
-
strict: true,
|
|
977
|
-
skipLibCheck: true,
|
|
978
|
-
},
|
|
979
|
-
include: ['**/*.ts'],
|
|
980
|
-
}, null, 2)}
|
|
981
|
-
`;
|
|
982
|
-
}
|
|
983
1161
|
function generateReadme(projectName) {
|
|
984
1162
|
return `# ${projectName}
|
|
985
1163
|
|
|
@@ -989,7 +1167,7 @@ This project uses ScreenCI + Playwright to create and upload polished product vi
|
|
|
989
1167
|
|
|
990
1168
|
Write video scripts in \`videos/*.video.ts\` and use \`video(...)\` calls to create product videos. These are very similar to Playwright \`.test.ts\` and \`test(...)\` calls.
|
|
991
1169
|
|
|
992
|
-
Learn more: https://screenci.com/docs
|
|
1170
|
+
Learn more: https://screenci.com/docs
|
|
993
1171
|
|
|
994
1172
|
## Quick start
|
|
995
1173
|
|
|
@@ -1014,11 +1192,7 @@ node_modules/
|
|
|
1014
1192
|
.env
|
|
1015
1193
|
`;
|
|
1016
1194
|
}
|
|
1017
|
-
function generateGithubAction(
|
|
1018
|
-
const packageLockPath = workingDirectory === '.'
|
|
1019
|
-
? 'package-lock.json'
|
|
1020
|
-
: `${workingDirectory}/package-lock.json`;
|
|
1021
|
-
const envFilePath = workingDirectory === '.' ? './.env' : `./${workingDirectory}/.env`;
|
|
1195
|
+
function generateGithubAction() {
|
|
1022
1196
|
return `name: ScreenCI
|
|
1023
1197
|
|
|
1024
1198
|
on:
|
|
@@ -1038,7 +1212,7 @@ jobs:
|
|
|
1038
1212
|
SCREENCI_SECRET: \${{ secrets.SCREENCI_SECRET }}
|
|
1039
1213
|
run: |
|
|
1040
1214
|
if [ -z "$SCREENCI_SECRET" ]; then
|
|
1041
|
-
echo "::error::SCREENCI_SECRET is not set. Copy it from https://app.screenci.com/secrets or
|
|
1215
|
+
echo "::error::SCREENCI_SECRET is not set. Copy it from https://app.screenci.com/secrets or ./.env, add it under Settings → Secrets and variables → Actions → Repository secrets, and then rerun this action."
|
|
1042
1216
|
exit 1
|
|
1043
1217
|
fi
|
|
1044
1218
|
|
|
@@ -1048,10 +1222,10 @@ jobs:
|
|
|
1048
1222
|
with:
|
|
1049
1223
|
node-version: 24
|
|
1050
1224
|
cache: npm
|
|
1051
|
-
cache-dependency-path:
|
|
1225
|
+
cache-dependency-path: package-lock.json
|
|
1052
1226
|
|
|
1053
1227
|
- name: Install dependencies
|
|
1054
|
-
working-directory:
|
|
1228
|
+
working-directory: .
|
|
1055
1229
|
run: npm ci
|
|
1056
1230
|
|
|
1057
1231
|
- name: Cache Playwright Chromium
|
|
@@ -1059,19 +1233,19 @@ jobs:
|
|
|
1059
1233
|
id: pw-cache
|
|
1060
1234
|
with:
|
|
1061
1235
|
path: ~/.cache/ms-playwright
|
|
1062
|
-
key: playwright-\${{ runner.os }}-\${{ hashFiles('
|
|
1236
|
+
key: playwright-\${{ runner.os }}-\${{ hashFiles('package-lock.json') }}
|
|
1063
1237
|
|
|
1064
1238
|
- name: Install Chromium
|
|
1065
1239
|
if: steps.pw-cache.outputs.cache-hit != 'true'
|
|
1066
|
-
working-directory:
|
|
1067
|
-
run: npx playwright install chromium
|
|
1240
|
+
working-directory: .
|
|
1241
|
+
run: npx playwright install chromium
|
|
1068
1242
|
|
|
1069
1243
|
- id: record
|
|
1070
1244
|
name: Record
|
|
1071
|
-
working-directory:
|
|
1245
|
+
working-directory: .
|
|
1072
1246
|
env:
|
|
1073
1247
|
SCREENCI_SECRET: \${{ secrets.SCREENCI_SECRET }}
|
|
1074
|
-
run:
|
|
1248
|
+
run: npx screenci record
|
|
1075
1249
|
`;
|
|
1076
1250
|
}
|
|
1077
1251
|
function openBrowser(url) {
|
|
@@ -1137,83 +1311,80 @@ function generateExampleVideo() {
|
|
|
1137
1311
|
return `import { autoZoom, createNarration, hide, video, voices } from 'screenci'
|
|
1138
1312
|
|
|
1139
1313
|
const narration = createNarration({
|
|
1140
|
-
voice: { name: voices.Sophie
|
|
1314
|
+
voice: { name: voices.Sophie },
|
|
1141
1315
|
languages: {
|
|
1142
1316
|
en: {
|
|
1143
1317
|
cues: {
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
es: {
|
|
1148
|
-
cues: {
|
|
1149
|
-
docs: 'Usa la barra lateral de guias para abrir la guia de edicion asistida por IA y revisar los siguientes pasos para escribir tus propios videos.',
|
|
1318
|
+
intro:
|
|
1319
|
+
'This video shows how to get started with ScreenCI [pronounce: screen see eye].',
|
|
1320
|
+
docs: 'You can find the documentation linked right on the front page.',
|
|
1150
1321
|
},
|
|
1151
1322
|
},
|
|
1152
1323
|
},
|
|
1153
1324
|
})
|
|
1154
1325
|
|
|
1155
|
-
video('
|
|
1326
|
+
video('How to get started', async ({ page }) => {
|
|
1156
1327
|
await hide(async () => {
|
|
1157
|
-
await page.goto('https://screenci.com
|
|
1158
|
-
await page.getByText('ScreenCI'
|
|
1328
|
+
await page.goto('https://screenci.com')
|
|
1329
|
+
await page.getByText('ScreenCI').first().waitFor()
|
|
1159
1330
|
})
|
|
1160
1331
|
|
|
1161
|
-
await
|
|
1162
|
-
async () => {
|
|
1163
|
-
await page.getByRole('link', { name: 'View Documentation' }).click()
|
|
1164
|
-
await page
|
|
1165
|
-
.getByRole('link', { name: 'AI-Supported Editing', exact: true })
|
|
1166
|
-
.click()
|
|
1167
|
-
await page.waitForTimeout(1000)
|
|
1168
|
-
},
|
|
1169
|
-
{ duration: 400, easing: 'ease-in-out', amount: 0.4 }
|
|
1170
|
-
)
|
|
1171
|
-
|
|
1332
|
+
await narration.intro()
|
|
1172
1333
|
await narration.docs()
|
|
1334
|
+
|
|
1335
|
+
await autoZoom(async () => {
|
|
1336
|
+
await page.getByRole('link', { name: 'View Documentation' }).click()
|
|
1337
|
+
})
|
|
1338
|
+
|
|
1339
|
+
await page.getByRole('heading', { level: 1, name: 'Installation' }).first().waitFor()
|
|
1173
1340
|
})
|
|
1174
1341
|
`;
|
|
1175
1342
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1343
|
+
function getDefaultInitProjectName() {
|
|
1344
|
+
const directoryName = basename(getInitProjectRoot());
|
|
1345
|
+
return directoryName.length > 0 ? directoryName : 'screenci-project';
|
|
1178
1346
|
}
|
|
1179
|
-
async function
|
|
1180
|
-
return
|
|
1181
|
-
message: '
|
|
1182
|
-
default:
|
|
1347
|
+
async function promptProjectName() {
|
|
1348
|
+
return input({
|
|
1349
|
+
message: 'Project name:',
|
|
1350
|
+
default: getDefaultInitProjectName(),
|
|
1183
1351
|
});
|
|
1184
1352
|
}
|
|
1185
|
-
async function
|
|
1186
|
-
|
|
1187
|
-
message
|
|
1188
|
-
default:
|
|
1353
|
+
async function promptYesNo(message, defaultValue) {
|
|
1354
|
+
const answer = await input({
|
|
1355
|
+
message,
|
|
1356
|
+
default: defaultValue ? 'y' : 'n',
|
|
1357
|
+
validate: (value) => {
|
|
1358
|
+
const normalized = value.trim().toLowerCase();
|
|
1359
|
+
if (normalized === '' ||
|
|
1360
|
+
normalized === 'y' ||
|
|
1361
|
+
normalized === 'yes' ||
|
|
1362
|
+
normalized === 'n' ||
|
|
1363
|
+
normalized === 'no') {
|
|
1364
|
+
return true;
|
|
1365
|
+
}
|
|
1366
|
+
return 'Enter y or n';
|
|
1367
|
+
},
|
|
1189
1368
|
});
|
|
1369
|
+
const normalized = answer.trim().toLowerCase();
|
|
1370
|
+
if (normalized === '')
|
|
1371
|
+
return defaultValue;
|
|
1372
|
+
return normalized === 'y' || normalized === 'yes';
|
|
1190
1373
|
}
|
|
1191
|
-
async function
|
|
1192
|
-
return
|
|
1193
|
-
message: 'Do you want to add Github Action CI? (Y/n)',
|
|
1194
|
-
default: true,
|
|
1195
|
-
});
|
|
1374
|
+
async function promptInitGithubActionWorkflow() {
|
|
1375
|
+
return promptYesNo('Add a GitHub Actions workflow? (Y/n)', true);
|
|
1196
1376
|
}
|
|
1197
|
-
async function
|
|
1198
|
-
return
|
|
1199
|
-
message: 'Initialize ScreenCI as a standalone project or part of the existing repository?',
|
|
1200
|
-
default: 'standalone',
|
|
1201
|
-
choices: [
|
|
1202
|
-
{
|
|
1203
|
-
name: 'Standalone project',
|
|
1204
|
-
value: 'standalone',
|
|
1205
|
-
description: 'Create a project directory with its own GitHub Action.',
|
|
1206
|
-
},
|
|
1207
|
-
{
|
|
1208
|
-
name: 'Part of existing repository',
|
|
1209
|
-
value: 'existing-repository',
|
|
1210
|
-
description: 'Create ./screenci and add the GitHub Action at the repository root.',
|
|
1211
|
-
},
|
|
1212
|
-
],
|
|
1213
|
-
});
|
|
1377
|
+
async function promptInitPlaywrightBrowsers() {
|
|
1378
|
+
return promptYesNo("Install Playwright browsers (can be done manually via 'npx playwright install chromium')? (Y/n)", true);
|
|
1214
1379
|
}
|
|
1215
|
-
function
|
|
1216
|
-
return
|
|
1380
|
+
async function promptInitPlaywrightOsDependencies() {
|
|
1381
|
+
return promptYesNo("Install Playwright operating system dependencies (might require sudo / root and can be done manually via 'npx playwright install-deps chromium')? (y/N)", false);
|
|
1382
|
+
}
|
|
1383
|
+
async function promptInitScreenCISkill() {
|
|
1384
|
+
return promptYesNo("Install the ScreenCI skill for AI agents (can be done manually via 'npx -y skills add screenci/screenci --skill screenci -y')? (Y/n)", true);
|
|
1385
|
+
}
|
|
1386
|
+
async function promptInitPlaywrightCliSkill() {
|
|
1387
|
+
return promptYesNo("Install playwright-cli for URL-based browser inspection (can be done manually via 'npx -y skills add screenci/screenci --skill playwright-cli -y && npm install @playwright/cli')? (Y/n)", true);
|
|
1217
1388
|
}
|
|
1218
1389
|
function getInitProjectRoot() {
|
|
1219
1390
|
return process.env['SCREENCI_INIT_CWD'] ?? process.cwd();
|
|
@@ -1221,7 +1392,7 @@ function getInitProjectRoot() {
|
|
|
1221
1392
|
function getInitScreenciDependencyOverride() {
|
|
1222
1393
|
return process.env['SCREENCI_INIT_SCREENCI_DEPENDENCY'];
|
|
1223
1394
|
}
|
|
1224
|
-
export async function ensureScreenciSecret() {
|
|
1395
|
+
export async function ensureScreenciSecret(resolvedConfigPath) {
|
|
1225
1396
|
const existingSecret = process.env.SCREENCI_SECRET;
|
|
1226
1397
|
if (existingSecret)
|
|
1227
1398
|
return existingSecret;
|
|
@@ -1230,8 +1401,11 @@ export async function ensureScreenciSecret() {
|
|
|
1230
1401
|
try {
|
|
1231
1402
|
const secret = await performBrowserLogin(appUrl);
|
|
1232
1403
|
process.env.SCREENCI_SECRET = secret;
|
|
1233
|
-
const savePath =
|
|
1234
|
-
|
|
1404
|
+
const savePath = resolvedConfigPath
|
|
1405
|
+
? ((await resolveConfiguredEnvFilePath(resolvedConfigPath)) ??
|
|
1406
|
+
resolve(process.cwd(), '.env'))
|
|
1407
|
+
: resolve(process.cwd(), '.env');
|
|
1408
|
+
await appendFile(savePath, `SCREENCI_SECRET=${secret}\n`);
|
|
1235
1409
|
logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
|
|
1236
1410
|
return secret;
|
|
1237
1411
|
}
|
|
@@ -1243,162 +1417,136 @@ export async function ensureScreenciSecret() {
|
|
|
1243
1417
|
}
|
|
1244
1418
|
}
|
|
1245
1419
|
async function runInit(projectNameArg, options) {
|
|
1246
|
-
const { verbose,
|
|
1420
|
+
const { verbose, yes, agent } = options;
|
|
1247
1421
|
const initCwd = getInitProjectRoot();
|
|
1248
|
-
const existingRepositoryDetected = existsSync(resolve(initCwd, '.git'));
|
|
1249
1422
|
let projectName = projectNameArg?.trim();
|
|
1250
1423
|
if (!projectName) {
|
|
1251
|
-
projectName = await promptProjectName();
|
|
1424
|
+
projectName = yes ? getDefaultInitProjectName() : await promptProjectName();
|
|
1252
1425
|
}
|
|
1253
1426
|
if (!projectName) {
|
|
1254
1427
|
logger.error('Error: Project name is required');
|
|
1255
1428
|
process.exit(1);
|
|
1256
1429
|
}
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
}
|
|
1260
|
-
const repositoryMode = existingRepositoryDetected
|
|
1261
|
-
? yes
|
|
1262
|
-
? 'standalone'
|
|
1263
|
-
: await promptInitRepositoryMode()
|
|
1264
|
-
: 'standalone';
|
|
1265
|
-
const isPartOfExistingRepository = repositoryMode === 'existing-repository';
|
|
1266
|
-
const dirName = isPartOfExistingRepository
|
|
1267
|
-
? 'screenci'
|
|
1268
|
-
: projectNameToDirectoryName(projectName);
|
|
1269
|
-
const projectDir = resolve(initCwd, dirName);
|
|
1270
|
-
const githubRootDir = isPartOfExistingRepository ? initCwd : projectDir;
|
|
1271
|
-
const githubDir = resolve(githubRootDir, '.github');
|
|
1272
|
-
const githubWorkflowsDir = resolve(githubDir, 'workflows');
|
|
1430
|
+
const projectDir = initCwd;
|
|
1431
|
+
const githubWorkflowsDir = resolve(projectDir, '.github', 'workflows');
|
|
1273
1432
|
const githubActionPath = resolve(githubWorkflowsDir, 'screenci.yaml');
|
|
1274
|
-
const
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
}
|
|
1279
|
-
const shouldInstallDependencies = yes
|
|
1433
|
+
const shouldAddGithubActionWorkflow = yes
|
|
1434
|
+
? true
|
|
1435
|
+
: await promptInitGithubActionWorkflow();
|
|
1436
|
+
const shouldInstallPlaywrightBrowsers = yes
|
|
1280
1437
|
? true
|
|
1281
|
-
:
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1438
|
+
: await promptInitPlaywrightBrowsers();
|
|
1439
|
+
const shouldInstallPlaywrightOsDependencies = yes
|
|
1440
|
+
? false
|
|
1441
|
+
: await promptInitPlaywrightOsDependencies();
|
|
1442
|
+
const shouldInstallScreenCISkill = yes
|
|
1285
1443
|
? true
|
|
1286
|
-
:
|
|
1287
|
-
|
|
1288
|
-
: await promptInitAiAuthoring();
|
|
1289
|
-
const shouldAddGithubActionCi = yes
|
|
1444
|
+
: await promptInitScreenCISkill();
|
|
1445
|
+
const shouldInstallPlaywrightCli = yes
|
|
1290
1446
|
? true
|
|
1291
|
-
:
|
|
1292
|
-
|
|
1293
|
-
: await promptInitGithubActionCi();
|
|
1294
|
-
if (shouldAddGithubActionCi && existsSync(githubActionPath)) {
|
|
1447
|
+
: await promptInitPlaywrightCliSkill();
|
|
1448
|
+
if (shouldAddGithubActionWorkflow && existsSync(githubActionPath)) {
|
|
1295
1449
|
logger.error('Error: GitHub Actions workflow ".github/workflows/screenci.yaml" already exists');
|
|
1296
1450
|
process.exit(1);
|
|
1297
1451
|
}
|
|
1298
|
-
const
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1452
|
+
const skills = [];
|
|
1453
|
+
if (shouldInstallScreenCISkill) {
|
|
1454
|
+
skills.push('screenci');
|
|
1455
|
+
}
|
|
1456
|
+
if (shouldInstallPlaywrightCli) {
|
|
1457
|
+
skills.push('playwright-cli');
|
|
1458
|
+
}
|
|
1459
|
+
const skillsArgs = skills.length === 0
|
|
1460
|
+
? null
|
|
1461
|
+
: [
|
|
1462
|
+
'-y',
|
|
1463
|
+
'skills',
|
|
1464
|
+
'add',
|
|
1465
|
+
'screenci/screenci',
|
|
1466
|
+
...(agent ? ['--agent', agent] : []),
|
|
1467
|
+
...skills.flatMap((skillName) => ['--skill', skillName]),
|
|
1468
|
+
'-y',
|
|
1469
|
+
];
|
|
1470
|
+
const skillsCommand = skillsArgs === null ? null : `npx ${skillsArgs.join(' ')}`;
|
|
1310
1471
|
const screenciDependency = getInitScreenciDependencyOverride() ?? (await readCurrentScreenciVersion());
|
|
1472
|
+
logger.info("Initializing project in '.'");
|
|
1311
1473
|
await mkdir(resolve(projectDir, 'videos'), { recursive: true });
|
|
1312
|
-
if (
|
|
1313
|
-
|
|
1314
|
-
await mkdir(githubDir);
|
|
1315
|
-
}
|
|
1316
|
-
if (!existsSync(githubWorkflowsDir)) {
|
|
1317
|
-
await mkdir(githubWorkflowsDir);
|
|
1318
|
-
}
|
|
1474
|
+
if (shouldAddGithubActionWorkflow) {
|
|
1475
|
+
await mkdir(githubWorkflowsDir, { recursive: true });
|
|
1319
1476
|
}
|
|
1320
1477
|
await writeFile(resolve(projectDir, 'screenci.config.ts'), generateConfig(projectName));
|
|
1321
|
-
await writeFile(resolve(projectDir, 'package.json'), generatePackageJson(
|
|
1322
|
-
await writeFile(resolve(projectDir, 'tsconfig.json'), generateTsconfig());
|
|
1478
|
+
await writeFile(resolve(projectDir, 'package.json'), generatePackageJson(shouldInstallPlaywrightCli, screenciDependency));
|
|
1323
1479
|
await writeFile(resolve(projectDir, 'README.md'), generateReadme(projectName));
|
|
1324
1480
|
await writeFile(resolve(projectDir, '.gitignore'), generateGitignore());
|
|
1325
1481
|
await writeFile(resolve(projectDir, 'videos', 'example.video.ts'), generateExampleVideo());
|
|
1326
|
-
if (
|
|
1327
|
-
await writeFile(githubActionPath, generateGithubAction(
|
|
1482
|
+
if (shouldAddGithubActionWorkflow) {
|
|
1483
|
+
await writeFile(githubActionPath, generateGithubAction());
|
|
1328
1484
|
}
|
|
1329
|
-
|
|
1330
|
-
logger.info(`Initialized screenci project "${projectName}" in ${projectDir}/`);
|
|
1485
|
+
logger.info(`Initialized screenci project "${projectName}" in .`);
|
|
1331
1486
|
logger.info('Files created:');
|
|
1332
1487
|
logger.info(' screenci.config.ts');
|
|
1333
1488
|
logger.info(' package.json');
|
|
1334
|
-
logger.info(' tsconfig.json');
|
|
1335
1489
|
logger.info(' README.md');
|
|
1336
1490
|
logger.info(' .gitignore');
|
|
1337
1491
|
logger.info(' videos/example.video.ts');
|
|
1338
|
-
if (
|
|
1339
|
-
|
|
1340
|
-
? '.github/workflows/screenci.yaml (outside ./screenci, at repository root)'
|
|
1341
|
-
: '.github/workflows/screenci.yaml';
|
|
1342
|
-
logger.info(` ${githubActionDisplayPath}`);
|
|
1492
|
+
if (shouldAddGithubActionWorkflow) {
|
|
1493
|
+
logger.info(' .github/workflows/screenci.yaml');
|
|
1343
1494
|
}
|
|
1344
|
-
logger.info(' .env (empty placeholder)');
|
|
1345
1495
|
logger.info('');
|
|
1346
|
-
if (
|
|
1496
|
+
if (skillsArgs !== null) {
|
|
1347
1497
|
if (verbose) {
|
|
1348
1498
|
logger.info(`Running '${skillsCommand}'...`);
|
|
1349
1499
|
await spawnInherited('npx', skillsArgs, projectDir, 'screenci init');
|
|
1350
1500
|
}
|
|
1351
1501
|
else {
|
|
1352
|
-
const spinner = ora('Adding
|
|
1502
|
+
const spinner = ora('Adding selected AI skills...').start();
|
|
1353
1503
|
try {
|
|
1354
1504
|
await spawnSilent('npx', skillsArgs, projectDir);
|
|
1355
|
-
spinner.succeed('
|
|
1505
|
+
spinner.succeed('Selected AI skills added');
|
|
1356
1506
|
}
|
|
1357
1507
|
catch (err) {
|
|
1358
|
-
spinner.fail('
|
|
1508
|
+
spinner.fail('AI skills install failed');
|
|
1359
1509
|
throw err;
|
|
1360
1510
|
}
|
|
1361
1511
|
}
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1512
|
+
}
|
|
1513
|
+
const installArgs = ['install', '--include=dev'];
|
|
1514
|
+
if (verbose) {
|
|
1515
|
+
logger.info(`Running 'npm ${installArgs.join(' ')}'...`);
|
|
1516
|
+
await spawnInherited('npm', installArgs, projectDir, 'screenci init');
|
|
1517
|
+
}
|
|
1518
|
+
else {
|
|
1519
|
+
const spinner = ora('Running npm install...').start();
|
|
1520
|
+
try {
|
|
1521
|
+
await spawnSilent('npm', installArgs, projectDir);
|
|
1522
|
+
spinner.succeed('npm install complete');
|
|
1366
1523
|
}
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
const installArgs = ['install', '--include=dev', '--prefix', projectDir];
|
|
1371
|
-
await spawnSilent('npm', installArgs);
|
|
1372
|
-
spinner.succeed('npm install complete');
|
|
1373
|
-
}
|
|
1374
|
-
catch (err) {
|
|
1375
|
-
spinner.fail('npm install failed');
|
|
1376
|
-
throw err;
|
|
1377
|
-
}
|
|
1524
|
+
catch (err) {
|
|
1525
|
+
spinner.fail('npm install failed');
|
|
1526
|
+
throw err;
|
|
1378
1527
|
}
|
|
1379
|
-
logger.info("Local development requires Chromium for Playwright, running 'npx playwright install chromium --with-deps'...");
|
|
1380
|
-
await spawnInherited('npx', ['playwright', 'install', 'chromium', '--with-deps'], projectDir, 'screenci init');
|
|
1381
|
-
logger.info(`${pc.green('✔')} Playwright installed successfully`);
|
|
1382
1528
|
}
|
|
1383
|
-
|
|
1384
|
-
logger.info(
|
|
1385
|
-
|
|
1386
|
-
logger.info(
|
|
1387
|
-
|
|
1388
|
-
|
|
1529
|
+
if (shouldInstallPlaywrightBrowsers) {
|
|
1530
|
+
logger.info("Installing Playwright Chromium with 'npx playwright install chromium'...");
|
|
1531
|
+
await spawnInherited('npx', ['playwright', 'install', 'chromium'], projectDir, 'screenci init');
|
|
1532
|
+
logger.info(`${pc.green('✔')} Playwright Chromium installed successfully`);
|
|
1533
|
+
}
|
|
1534
|
+
if (shouldInstallPlaywrightOsDependencies) {
|
|
1535
|
+
logger.info("Installing Playwright operating system dependencies with 'npx playwright install-deps chromium'...");
|
|
1536
|
+
await spawnInherited('npx', ['playwright', 'install-deps', 'chromium'], projectDir, 'screenci init');
|
|
1537
|
+
logger.info(`${pc.green('✔')} Playwright operating system dependencies installed successfully`);
|
|
1389
1538
|
}
|
|
1390
1539
|
logger.info('');
|
|
1391
1540
|
logger.info('Next steps:');
|
|
1392
|
-
logger.info(` cd ${dirName}`);
|
|
1393
1541
|
logger.info(' Read README.md for setup and recording flow');
|
|
1394
|
-
logger.info(' Docs: https://screenci.com/docs
|
|
1542
|
+
logger.info(' Docs: https://screenci.com/docs');
|
|
1395
1543
|
logger.info(' npx screenci test');
|
|
1396
1544
|
logger.info(' npx screenci record');
|
|
1397
1545
|
}
|
|
1398
1546
|
export async function main() {
|
|
1399
1547
|
if (process.argv.length <= 2) {
|
|
1400
1548
|
logger.error('Error: No command provided');
|
|
1401
|
-
logger.error('Available commands: record, test, info, make-public, make-private,
|
|
1549
|
+
logger.error('Available commands: record, test, info, make-public, make-private, init');
|
|
1402
1550
|
process.exit(1);
|
|
1403
1551
|
}
|
|
1404
1552
|
const program = new Command();
|
|
@@ -1412,7 +1560,20 @@ export async function main() {
|
|
|
1412
1560
|
.allowUnknownOption(true)
|
|
1413
1561
|
.action(async () => {
|
|
1414
1562
|
const parsed = parseRecordCliArgs(getSubcommandArgv('record'));
|
|
1415
|
-
|
|
1563
|
+
let playwrightFailure = null;
|
|
1564
|
+
try {
|
|
1565
|
+
await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose);
|
|
1566
|
+
}
|
|
1567
|
+
catch (error) {
|
|
1568
|
+
logRecordFailureHint();
|
|
1569
|
+
if (error instanceof Error &&
|
|
1570
|
+
error.message.startsWith('Playwright exited with code ')) {
|
|
1571
|
+
playwrightFailure = error;
|
|
1572
|
+
}
|
|
1573
|
+
else {
|
|
1574
|
+
throw error;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1416
1577
|
if (process.env.SCREENCI_RECORDING === 'true')
|
|
1417
1578
|
return;
|
|
1418
1579
|
// After recording, upload results to API if configured
|
|
@@ -1427,39 +1588,76 @@ export async function main() {
|
|
|
1427
1588
|
const apiUrl = getDevBackendUrl();
|
|
1428
1589
|
const appUrl = getDevFrontendUrl();
|
|
1429
1590
|
const secret = process.env.SCREENCI_SECRET;
|
|
1430
|
-
|
|
1431
|
-
logger.info('No secret configured, skipping upload. Set SCREENCI_SECRET in your .env file.');
|
|
1432
|
-
return;
|
|
1433
|
-
}
|
|
1591
|
+
const uploadPolicy = resolveRecordUploadPolicy(screenciConfig);
|
|
1434
1592
|
const configDir = dirname(resolvedConfigPath);
|
|
1435
1593
|
const screenciDir = resolve(configDir, '.screenci');
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
logger.info('');
|
|
1439
|
-
projectId = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret);
|
|
1594
|
+
const completedRecordingCount = await countCompletedRecordings(screenciDir);
|
|
1595
|
+
if (playwrightFailure !== null && completedRecordingCount === 0) {
|
|
1596
|
+
logger.info('All recordings failed.');
|
|
1440
1597
|
}
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
process.exit(130);
|
|
1444
|
-
}
|
|
1445
|
-
throw err;
|
|
1598
|
+
else if (!secret) {
|
|
1599
|
+
logger.info('No secret configured, skipping upload. Set SCREENCI_SECRET in your .env file.');
|
|
1446
1600
|
}
|
|
1447
|
-
if (
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1601
|
+
else if (playwrightFailure !== null &&
|
|
1602
|
+
uploadPolicy === 'all-or-nothing') {
|
|
1603
|
+
logger.info('Some recordings failed, skipping upload because record.upload is "all-or-nothing".');
|
|
1604
|
+
}
|
|
1605
|
+
else {
|
|
1606
|
+
if (playwrightFailure !== null && uploadPolicy === 'passed-only') {
|
|
1607
|
+
logger.warn('Some recordings failed, uploading successful videos only.');
|
|
1608
|
+
}
|
|
1609
|
+
let uploadResult = {
|
|
1610
|
+
projectId: null,
|
|
1611
|
+
hadFailures: false,
|
|
1612
|
+
failedVideoNames: [],
|
|
1613
|
+
failedVideoMessages: [],
|
|
1614
|
+
};
|
|
1615
|
+
try {
|
|
1616
|
+
logger.info('');
|
|
1617
|
+
uploadResult = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret);
|
|
1618
|
+
}
|
|
1619
|
+
catch (err) {
|
|
1620
|
+
if (isUploadCancelledError(err)) {
|
|
1621
|
+
process.exit(130);
|
|
1622
|
+
}
|
|
1623
|
+
throw err;
|
|
1624
|
+
}
|
|
1625
|
+
const { projectId, hadFailures, failedVideoNames, failedVideoMessages, } = uploadResult;
|
|
1626
|
+
if (projectId !== null) {
|
|
1627
|
+
const projectUrl = `${appUrl}/project/${projectId}`;
|
|
1628
|
+
await writeGitHubProjectOutput(projectUrl);
|
|
1629
|
+
logger.info('');
|
|
1630
|
+
logger.info(playwrightFailure !== null
|
|
1631
|
+
? 'Recording partially succeeded, rendering in progress. Results available at:'
|
|
1632
|
+
: 'Recording finished, rendering in progress. Results available at:');
|
|
1633
|
+
logger.info(pc.cyan(projectUrl));
|
|
1634
|
+
}
|
|
1635
|
+
if (hadFailures) {
|
|
1636
|
+
for (const failedVideo of failedVideoMessages) {
|
|
1637
|
+
logger.warn(`${failedVideo.videoName}: ${failedVideo.message}`);
|
|
1638
|
+
}
|
|
1639
|
+
logger.warn(`Not all recordings succeeded to upload. Failed videos: ${failedVideoNames.join(', ') || 'unknown'}. Some videos may be missing from the project.`);
|
|
1640
|
+
if (playwrightFailure === null) {
|
|
1641
|
+
throw new PartialUploadError();
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1453
1644
|
}
|
|
1454
1645
|
}
|
|
1455
1646
|
catch (err) {
|
|
1647
|
+
if (isPartialUploadError(err)) {
|
|
1648
|
+
throw err;
|
|
1649
|
+
}
|
|
1456
1650
|
logger.warn('Failed to load config for upload:', err);
|
|
1457
1651
|
}
|
|
1458
1652
|
}
|
|
1653
|
+
if (playwrightFailure !== null) {
|
|
1654
|
+
throw playwrightFailure;
|
|
1655
|
+
}
|
|
1459
1656
|
});
|
|
1460
1657
|
program
|
|
1461
1658
|
.command('test [playwrightArgs...]')
|
|
1462
1659
|
.description('Run Playwright test with screenci.config.ts')
|
|
1660
|
+
.option('--mock-record', 'keep recording-style cursor animation and sleeps during screenci test')
|
|
1463
1661
|
.option('-v, --verbose', 'verbose output')
|
|
1464
1662
|
.allowUnknownOption(true)
|
|
1465
1663
|
.action(async () => {
|
|
@@ -1477,7 +1675,7 @@ export async function main() {
|
|
|
1477
1675
|
logger.warn('Failed to load config for test env:', err);
|
|
1478
1676
|
}
|
|
1479
1677
|
}
|
|
1480
|
-
await run('test', parsed.otherArgs, parsed.configPath, parsed.verbose);
|
|
1678
|
+
await run('test', parsed.otherArgs, parsed.configPath, parsed.verbose, parsed.mockRecord);
|
|
1481
1679
|
if (process.env.SCREENCI_RECORDING === 'true')
|
|
1482
1680
|
return;
|
|
1483
1681
|
logger.info('');
|
|
@@ -1504,33 +1702,18 @@ export async function main() {
|
|
|
1504
1702
|
.action(async (id, options) => {
|
|
1505
1703
|
await updateVideoVisibility(id, false, options['config']);
|
|
1506
1704
|
});
|
|
1507
|
-
// retry command
|
|
1508
|
-
program
|
|
1509
|
-
.command('retry')
|
|
1510
|
-
.description('Retry uploading all pending recordings')
|
|
1511
|
-
.option('-c, --config <path>', 'path to screenci.config.ts')
|
|
1512
|
-
.option('-v, --verbose', 'verbose output')
|
|
1513
|
-
.action(async (options) => {
|
|
1514
|
-
await uploadLatest(options['config'], options['verbose'] ?? false);
|
|
1515
|
-
});
|
|
1516
1705
|
// init command
|
|
1517
1706
|
program
|
|
1518
1707
|
.command('init [name]')
|
|
1519
1708
|
.description('Initialize a new screenci project')
|
|
1520
|
-
.option('--install', 'install skills, dependencies, and Chromium without prompting')
|
|
1521
|
-
.option('--ci', 'add GitHub Action CI without prompting')
|
|
1522
1709
|
.option('--agent <name>', 'target agent for skills install, e.g. opencode. Supported agents: https://github.com/vercel-labs/skills#supported-agents')
|
|
1523
|
-
.option('--
|
|
1524
|
-
.option('-y, --yes', 'answer yes to all init prompts')
|
|
1710
|
+
.option('-y, --yes', 'accept init defaults')
|
|
1525
1711
|
.option('-v, --verbose', 'verbose output')
|
|
1526
1712
|
.action(async (name, options) => {
|
|
1527
1713
|
const agent = options['agent'];
|
|
1528
1714
|
await runInit(name, {
|
|
1529
1715
|
verbose: options['verbose'] ?? false,
|
|
1530
|
-
install: options['install'] ?? false,
|
|
1531
1716
|
yes: options['yes'] ?? false,
|
|
1532
|
-
skill: options['skill'] ?? false,
|
|
1533
|
-
ci: options['ci'] ?? false,
|
|
1534
1717
|
...(agent !== undefined ? { agent } : {}),
|
|
1535
1718
|
});
|
|
1536
1719
|
});
|
|
@@ -1603,6 +1786,7 @@ function parseRecordCliArgs(args) {
|
|
|
1603
1786
|
function parseConfigCliArgs(args) {
|
|
1604
1787
|
let configPath;
|
|
1605
1788
|
let verbose = false;
|
|
1789
|
+
let mockRecord = false;
|
|
1606
1790
|
const otherArgs = [];
|
|
1607
1791
|
for (let i = 0; i < args.length; i++) {
|
|
1608
1792
|
const arg = args[i];
|
|
@@ -1620,28 +1804,29 @@ function parseConfigCliArgs(args) {
|
|
|
1620
1804
|
else if (arg === '--verbose' || arg === '-v') {
|
|
1621
1805
|
verbose = true;
|
|
1622
1806
|
}
|
|
1807
|
+
else if (arg === '--mock-record') {
|
|
1808
|
+
mockRecord = true;
|
|
1809
|
+
}
|
|
1623
1810
|
else {
|
|
1624
1811
|
otherArgs.push(arg);
|
|
1625
1812
|
}
|
|
1626
1813
|
}
|
|
1627
|
-
return { configPath, verbose, otherArgs };
|
|
1814
|
+
return { configPath, verbose, mockRecord, otherArgs };
|
|
1628
1815
|
}
|
|
1629
1816
|
function validateArgs(args) {
|
|
1630
|
-
const disallowedFlags = ['--
|
|
1817
|
+
const disallowedFlags = ['--retries'];
|
|
1631
1818
|
for (const arg of args) {
|
|
1632
1819
|
if (arg === undefined)
|
|
1633
1820
|
continue;
|
|
1634
1821
|
// Check if it's a disallowed flag
|
|
1635
1822
|
if (disallowedFlags.includes(arg)) {
|
|
1636
1823
|
throw new Error(`Flag "${arg}" is not supported by screenci. ` +
|
|
1637
|
-
'screenci
|
|
1824
|
+
'screenci forces retries to 0 for proper video recording.');
|
|
1638
1825
|
}
|
|
1639
|
-
// Check if it's a --
|
|
1640
|
-
if (arg.startsWith('--
|
|
1641
|
-
arg.startsWith('-j=') ||
|
|
1642
|
-
arg.startsWith('--retries=')) {
|
|
1826
|
+
// Check if it's a --retries=N format
|
|
1827
|
+
if (arg.startsWith('--retries=')) {
|
|
1643
1828
|
throw new Error(`Flag "${arg}" is not supported by screenci. ` +
|
|
1644
|
-
'screenci
|
|
1829
|
+
'screenci forces retries to 0 for proper video recording.');
|
|
1645
1830
|
}
|
|
1646
1831
|
}
|
|
1647
1832
|
}
|
|
@@ -1678,7 +1863,7 @@ function spawnInherited(cmd, args, cwd, activityLabel = cmd) {
|
|
|
1678
1863
|
});
|
|
1679
1864
|
});
|
|
1680
1865
|
}
|
|
1681
|
-
async function run(command, additionalArgs, customConfigPath, verbose = false) {
|
|
1866
|
+
async function run(command, additionalArgs, customConfigPath, verbose = false, mockRecord = false) {
|
|
1682
1867
|
const configPath = findScreenCIConfig(customConfigPath);
|
|
1683
1868
|
if (!configPath) {
|
|
1684
1869
|
const errorMsg = customConfigPath
|
|
@@ -1690,14 +1875,24 @@ async function run(command, additionalArgs, customConfigPath, verbose = false) {
|
|
|
1690
1875
|
if (command === 'test' || process.env.SCREENCI_RECORDING !== 'true') {
|
|
1691
1876
|
await loadEnvFileFromConfigSource(configPath, false);
|
|
1692
1877
|
}
|
|
1693
|
-
const envForChild = { ...process.env };
|
|
1694
1878
|
// Only validate args for record command
|
|
1695
1879
|
if (command === 'record') {
|
|
1696
|
-
await ensureScreenciSecret();
|
|
1880
|
+
await ensureScreenciSecret(configPath);
|
|
1697
1881
|
validateArgs(additionalArgs);
|
|
1698
1882
|
const screenciDir = resolve(dirname(configPath), '.screenci');
|
|
1699
1883
|
clearDirectory(screenciDir);
|
|
1700
1884
|
}
|
|
1885
|
+
const envForChild = { ...process.env };
|
|
1886
|
+
await validateUniqueDiscoveredTestTitles(configPath, additionalArgs, {
|
|
1887
|
+
...envForChild,
|
|
1888
|
+
...(command === 'record' ? { SCREENCI_RECORDING: 'true' } : {}),
|
|
1889
|
+
...(command === 'test' && !mockRecord
|
|
1890
|
+
? { [SCREENCI_DISABLE_RECORDING_TIMINGS_ENV]: 'true' }
|
|
1891
|
+
: {}),
|
|
1892
|
+
...(command === 'test' && mockRecord
|
|
1893
|
+
? { [SCREENCI_MOCK_RECORD_ENV]: 'true' }
|
|
1894
|
+
: {}),
|
|
1895
|
+
});
|
|
1701
1896
|
if (verbose && process.env.SCREENCI_RECORDING !== 'true') {
|
|
1702
1897
|
logger.info(`Using config: ${configPath}`);
|
|
1703
1898
|
}
|
|
@@ -1705,32 +1900,44 @@ async function run(command, additionalArgs, customConfigPath, verbose = false) {
|
|
|
1705
1900
|
const spawnSpec = resolveSpawnSpec('playwright', playwrightArgs);
|
|
1706
1901
|
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
1707
1902
|
stdio: 'inherit',
|
|
1903
|
+
...(process.platform !== 'win32' ? { detached: true } : {}),
|
|
1708
1904
|
...(spawnSpec.shell !== undefined ? { shell: spawnSpec.shell } : {}),
|
|
1709
1905
|
env: {
|
|
1710
1906
|
...envForChild,
|
|
1711
1907
|
// Enable recording only for record command
|
|
1712
1908
|
...(command === 'record' ? { SCREENCI_RECORDING: 'true' } : {}),
|
|
1909
|
+
...(command === 'test' && !mockRecord
|
|
1910
|
+
? { [SCREENCI_DISABLE_RECORDING_TIMINGS_ENV]: 'true' }
|
|
1911
|
+
: {}),
|
|
1912
|
+
...(command === 'test' && mockRecord
|
|
1913
|
+
? { [SCREENCI_MOCK_RECORD_ENV]: 'true' }
|
|
1914
|
+
: {}),
|
|
1713
1915
|
},
|
|
1714
1916
|
});
|
|
1715
|
-
const childSignals = forwardChildSignals(child, `screenci ${command}
|
|
1917
|
+
const childSignals = forwardChildSignals(child, `screenci ${command}`, {
|
|
1918
|
+
killTree: process.platform !== 'win32',
|
|
1919
|
+
exitParentOnForward: true,
|
|
1920
|
+
});
|
|
1716
1921
|
return new Promise((resolve, reject) => {
|
|
1717
1922
|
child.on('close', (code, signal) => {
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1923
|
+
void (async () => {
|
|
1924
|
+
const forwardedSignal = childSignals.getForwardedSignal();
|
|
1925
|
+
childSignals.cleanup();
|
|
1926
|
+
if (forwardedSignal) {
|
|
1927
|
+
process.kill(process.pid, forwardedSignal);
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
if (signal) {
|
|
1931
|
+
process.kill(process.pid, signal);
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
if (code === 0) {
|
|
1935
|
+
resolve();
|
|
1936
|
+
}
|
|
1937
|
+
else {
|
|
1938
|
+
reject(new Error(`Playwright exited with code ${code}`));
|
|
1939
|
+
}
|
|
1940
|
+
})().catch(reject);
|
|
1734
1941
|
});
|
|
1735
1942
|
child.on('error', (err) => {
|
|
1736
1943
|
childSignals.cleanup();
|
|
@@ -1738,6 +1945,11 @@ async function run(command, additionalArgs, customConfigPath, verbose = false) {
|
|
|
1738
1945
|
});
|
|
1739
1946
|
});
|
|
1740
1947
|
}
|
|
1948
|
+
function logRecordFailureHint() {
|
|
1949
|
+
logger.info('');
|
|
1950
|
+
logger.info(`If ${pc.cyan('screenci test')} works but ${pc.cyan('screenci record')} fails, try ${pc.cyan('screenci test --mock-record')}.`);
|
|
1951
|
+
logger.info(`More info: ${pc.cyan(SCREENCI_MOCK_RECORD_DOCS_URL)}`);
|
|
1952
|
+
}
|
|
1741
1953
|
// Only run if this file is being executed directly
|
|
1742
1954
|
// Check if this module is the main module (handles symlinks properly)
|
|
1743
1955
|
const currentFile = fileURLToPath(import.meta.url);
|
|
@@ -1748,6 +1960,9 @@ if (mainFile &&
|
|
|
1748
1960
|
currentRealFile === mainFile ||
|
|
1749
1961
|
currentFile === realpathSync(mainFile))) {
|
|
1750
1962
|
main().catch((error) => {
|
|
1963
|
+
if (isPartialUploadError(error)) {
|
|
1964
|
+
process.exit(1);
|
|
1965
|
+
}
|
|
1751
1966
|
logger.error('Error:', error.message);
|
|
1752
1967
|
process.exit(1);
|
|
1753
1968
|
});
|