screenci 0.0.43 → 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 +685 -464
- 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 +5 -5
- 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 +70 -101
- 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 -9
- package/dist/src/defaults.d.ts.map +1 -1
- package/dist/src/defaults.js +11 -9
- 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 +33 -17
- 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
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
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);
|
|
1379
|
+
}
|
|
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);
|
|
1214
1385
|
}
|
|
1215
|
-
function
|
|
1216
|
-
return
|
|
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
|
-
if (existsSync(projectDir)) {
|
|
1276
|
-
logger.error(`Error: Directory "${dirName}" already exists`);
|
|
1277
|
-
process.exit(1);
|
|
1278
|
-
}
|
|
1279
|
-
const shouldInstallDependencies = yes
|
|
1433
|
+
const shouldAddGithubActionWorkflow = yes
|
|
1280
1434
|
? true
|
|
1281
|
-
:
|
|
1282
|
-
|
|
1283
|
-
: await promptInitDependencies();
|
|
1284
|
-
const shouldAddPlaywrightCli = yes
|
|
1435
|
+
: await promptInitGithubActionWorkflow();
|
|
1436
|
+
const shouldInstallPlaywrightBrowsers = yes
|
|
1285
1437
|
? true
|
|
1286
|
-
:
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1438
|
+
: await promptInitPlaywrightBrowsers();
|
|
1439
|
+
const shouldInstallPlaywrightOsDependencies = yes
|
|
1440
|
+
? false
|
|
1441
|
+
: await promptInitPlaywrightOsDependencies();
|
|
1442
|
+
const shouldInstallScreenCISkill = yes
|
|
1290
1443
|
? true
|
|
1291
|
-
:
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1444
|
+
: await promptInitScreenCISkill();
|
|
1445
|
+
const shouldInstallPlaywrightCli = yes
|
|
1446
|
+
? true
|
|
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();
|
|
@@ -1408,10 +1556,24 @@ export async function main() {
|
|
|
1408
1556
|
program
|
|
1409
1557
|
.command('record [playwrightArgs...]')
|
|
1410
1558
|
.description('Record videos using Playwright')
|
|
1559
|
+
.option('-v, --verbose', 'verbose output')
|
|
1411
1560
|
.allowUnknownOption(true)
|
|
1412
1561
|
.action(async () => {
|
|
1413
1562
|
const parsed = parseRecordCliArgs(getSubcommandArgv('record'));
|
|
1414
|
-
|
|
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
|
+
}
|
|
1415
1577
|
if (process.env.SCREENCI_RECORDING === 'true')
|
|
1416
1578
|
return;
|
|
1417
1579
|
// After recording, upload results to API if configured
|
|
@@ -1426,39 +1588,77 @@ export async function main() {
|
|
|
1426
1588
|
const apiUrl = getDevBackendUrl();
|
|
1427
1589
|
const appUrl = getDevFrontendUrl();
|
|
1428
1590
|
const secret = process.env.SCREENCI_SECRET;
|
|
1429
|
-
|
|
1430
|
-
logger.info('No secret configured, skipping upload. Set SCREENCI_SECRET in your .env file.');
|
|
1431
|
-
return;
|
|
1432
|
-
}
|
|
1591
|
+
const uploadPolicy = resolveRecordUploadPolicy(screenciConfig);
|
|
1433
1592
|
const configDir = dirname(resolvedConfigPath);
|
|
1434
1593
|
const screenciDir = resolve(configDir, '.screenci');
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
logger.info('');
|
|
1438
|
-
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.');
|
|
1439
1597
|
}
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1598
|
+
else if (!secret) {
|
|
1599
|
+
logger.info('No secret configured, skipping upload. Set SCREENCI_SECRET in your .env file.');
|
|
1600
|
+
}
|
|
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".');
|
|
1445
1604
|
}
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
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
|
+
}
|
|
1452
1644
|
}
|
|
1453
1645
|
}
|
|
1454
1646
|
catch (err) {
|
|
1647
|
+
if (isPartialUploadError(err)) {
|
|
1648
|
+
throw err;
|
|
1649
|
+
}
|
|
1455
1650
|
logger.warn('Failed to load config for upload:', err);
|
|
1456
1651
|
}
|
|
1457
1652
|
}
|
|
1653
|
+
if (playwrightFailure !== null) {
|
|
1654
|
+
throw playwrightFailure;
|
|
1655
|
+
}
|
|
1458
1656
|
});
|
|
1459
1657
|
program
|
|
1460
1658
|
.command('test [playwrightArgs...]')
|
|
1461
1659
|
.description('Run Playwright test with screenci.config.ts')
|
|
1660
|
+
.option('--mock-record', 'keep recording-style cursor animation and sleeps during screenci test')
|
|
1661
|
+
.option('-v, --verbose', 'verbose output')
|
|
1462
1662
|
.allowUnknownOption(true)
|
|
1463
1663
|
.action(async () => {
|
|
1464
1664
|
const parsed = parseConfigCliArgs(getSubcommandArgv('test'));
|
|
@@ -1475,7 +1675,7 @@ export async function main() {
|
|
|
1475
1675
|
logger.warn('Failed to load config for test env:', err);
|
|
1476
1676
|
}
|
|
1477
1677
|
}
|
|
1478
|
-
await run('test', parsed.otherArgs, parsed.configPath);
|
|
1678
|
+
await run('test', parsed.otherArgs, parsed.configPath, parsed.verbose, parsed.mockRecord);
|
|
1479
1679
|
if (process.env.SCREENCI_RECORDING === 'true')
|
|
1480
1680
|
return;
|
|
1481
1681
|
logger.info('');
|
|
@@ -1502,33 +1702,18 @@ export async function main() {
|
|
|
1502
1702
|
.action(async (id, options) => {
|
|
1503
1703
|
await updateVideoVisibility(id, false, options['config']);
|
|
1504
1704
|
});
|
|
1505
|
-
// retry command
|
|
1506
|
-
program
|
|
1507
|
-
.command('retry')
|
|
1508
|
-
.description('Retry uploading all pending recordings')
|
|
1509
|
-
.option('-c, --config <path>', 'path to screenci.config.ts')
|
|
1510
|
-
.option('-v, --verbose', 'verbose output')
|
|
1511
|
-
.action(async (options) => {
|
|
1512
|
-
await uploadLatest(options['config'], options['verbose'] ?? false);
|
|
1513
|
-
});
|
|
1514
1705
|
// init command
|
|
1515
1706
|
program
|
|
1516
1707
|
.command('init [name]')
|
|
1517
1708
|
.description('Initialize a new screenci project')
|
|
1518
|
-
.option('--install', 'install skills, dependencies, and Chromium without prompting')
|
|
1519
|
-
.option('--ci', 'add GitHub Action CI without prompting')
|
|
1520
1709
|
.option('--agent <name>', 'target agent for skills install, e.g. opencode. Supported agents: https://github.com/vercel-labs/skills#supported-agents')
|
|
1521
|
-
.option('--
|
|
1522
|
-
.option('-y, --yes', 'answer yes to all init prompts')
|
|
1710
|
+
.option('-y, --yes', 'accept init defaults')
|
|
1523
1711
|
.option('-v, --verbose', 'verbose output')
|
|
1524
1712
|
.action(async (name, options) => {
|
|
1525
1713
|
const agent = options['agent'];
|
|
1526
1714
|
await runInit(name, {
|
|
1527
1715
|
verbose: options['verbose'] ?? false,
|
|
1528
|
-
install: options['install'] ?? false,
|
|
1529
1716
|
yes: options['yes'] ?? false,
|
|
1530
|
-
skill: options['skill'] ?? false,
|
|
1531
|
-
ci: options['ci'] ?? false,
|
|
1532
1717
|
...(agent !== undefined ? { agent } : {}),
|
|
1533
1718
|
});
|
|
1534
1719
|
});
|
|
@@ -1600,6 +1785,8 @@ function parseRecordCliArgs(args) {
|
|
|
1600
1785
|
}
|
|
1601
1786
|
function parseConfigCliArgs(args) {
|
|
1602
1787
|
let configPath;
|
|
1788
|
+
let verbose = false;
|
|
1789
|
+
let mockRecord = false;
|
|
1603
1790
|
const otherArgs = [];
|
|
1604
1791
|
for (let i = 0; i < args.length; i++) {
|
|
1605
1792
|
const arg = args[i];
|
|
@@ -1614,28 +1801,32 @@ function parseConfigCliArgs(args) {
|
|
|
1614
1801
|
configPath = nextArg;
|
|
1615
1802
|
i++;
|
|
1616
1803
|
}
|
|
1804
|
+
else if (arg === '--verbose' || arg === '-v') {
|
|
1805
|
+
verbose = true;
|
|
1806
|
+
}
|
|
1807
|
+
else if (arg === '--mock-record') {
|
|
1808
|
+
mockRecord = true;
|
|
1809
|
+
}
|
|
1617
1810
|
else {
|
|
1618
1811
|
otherArgs.push(arg);
|
|
1619
1812
|
}
|
|
1620
1813
|
}
|
|
1621
|
-
return { configPath, otherArgs };
|
|
1814
|
+
return { configPath, verbose, mockRecord, otherArgs };
|
|
1622
1815
|
}
|
|
1623
1816
|
function validateArgs(args) {
|
|
1624
|
-
const disallowedFlags = ['--
|
|
1817
|
+
const disallowedFlags = ['--retries'];
|
|
1625
1818
|
for (const arg of args) {
|
|
1626
1819
|
if (arg === undefined)
|
|
1627
1820
|
continue;
|
|
1628
1821
|
// Check if it's a disallowed flag
|
|
1629
1822
|
if (disallowedFlags.includes(arg)) {
|
|
1630
1823
|
throw new Error(`Flag "${arg}" is not supported by screenci. ` +
|
|
1631
|
-
'screenci
|
|
1824
|
+
'screenci forces retries to 0 for proper video recording.');
|
|
1632
1825
|
}
|
|
1633
|
-
// Check if it's a --
|
|
1634
|
-
if (arg.startsWith('--
|
|
1635
|
-
arg.startsWith('-j=') ||
|
|
1636
|
-
arg.startsWith('--retries=')) {
|
|
1826
|
+
// Check if it's a --retries=N format
|
|
1827
|
+
if (arg.startsWith('--retries=')) {
|
|
1637
1828
|
throw new Error(`Flag "${arg}" is not supported by screenci. ` +
|
|
1638
|
-
'screenci
|
|
1829
|
+
'screenci forces retries to 0 for proper video recording.');
|
|
1639
1830
|
}
|
|
1640
1831
|
}
|
|
1641
1832
|
}
|
|
@@ -1672,7 +1863,7 @@ function spawnInherited(cmd, args, cwd, activityLabel = cmd) {
|
|
|
1672
1863
|
});
|
|
1673
1864
|
});
|
|
1674
1865
|
}
|
|
1675
|
-
async function run(command, additionalArgs, customConfigPath) {
|
|
1866
|
+
async function run(command, additionalArgs, customConfigPath, verbose = false, mockRecord = false) {
|
|
1676
1867
|
const configPath = findScreenCIConfig(customConfigPath);
|
|
1677
1868
|
if (!configPath) {
|
|
1678
1869
|
const errorMsg = customConfigPath
|
|
@@ -1684,47 +1875,69 @@ async function run(command, additionalArgs, customConfigPath) {
|
|
|
1684
1875
|
if (command === 'test' || process.env.SCREENCI_RECORDING !== 'true') {
|
|
1685
1876
|
await loadEnvFileFromConfigSource(configPath, false);
|
|
1686
1877
|
}
|
|
1687
|
-
const envForChild = { ...process.env };
|
|
1688
1878
|
// Only validate args for record command
|
|
1689
1879
|
if (command === 'record') {
|
|
1690
|
-
await ensureScreenciSecret();
|
|
1880
|
+
await ensureScreenciSecret(configPath);
|
|
1691
1881
|
validateArgs(additionalArgs);
|
|
1692
1882
|
const screenciDir = resolve(dirname(configPath), '.screenci');
|
|
1693
1883
|
clearDirectory(screenciDir);
|
|
1694
1884
|
}
|
|
1695
|
-
|
|
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
|
+
});
|
|
1896
|
+
if (verbose && process.env.SCREENCI_RECORDING !== 'true') {
|
|
1696
1897
|
logger.info(`Using config: ${configPath}`);
|
|
1697
1898
|
}
|
|
1698
1899
|
const playwrightArgs = ['test', '--config', configPath, ...additionalArgs];
|
|
1699
1900
|
const spawnSpec = resolveSpawnSpec('playwright', playwrightArgs);
|
|
1700
1901
|
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
1701
1902
|
stdio: 'inherit',
|
|
1903
|
+
...(process.platform !== 'win32' ? { detached: true } : {}),
|
|
1702
1904
|
...(spawnSpec.shell !== undefined ? { shell: spawnSpec.shell } : {}),
|
|
1703
1905
|
env: {
|
|
1704
1906
|
...envForChild,
|
|
1705
1907
|
// Enable recording only for record command
|
|
1706
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
|
+
: {}),
|
|
1707
1915
|
},
|
|
1708
1916
|
});
|
|
1709
|
-
const childSignals = forwardChildSignals(child, `screenci ${command}
|
|
1917
|
+
const childSignals = forwardChildSignals(child, `screenci ${command}`, {
|
|
1918
|
+
killTree: process.platform !== 'win32',
|
|
1919
|
+
exitParentOnForward: true,
|
|
1920
|
+
});
|
|
1710
1921
|
return new Promise((resolve, reject) => {
|
|
1711
1922
|
child.on('close', (code, signal) => {
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
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);
|
|
1728
1941
|
});
|
|
1729
1942
|
child.on('error', (err) => {
|
|
1730
1943
|
childSignals.cleanup();
|
|
@@ -1732,6 +1945,11 @@ async function run(command, additionalArgs, customConfigPath) {
|
|
|
1732
1945
|
});
|
|
1733
1946
|
});
|
|
1734
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
|
+
}
|
|
1735
1953
|
// Only run if this file is being executed directly
|
|
1736
1954
|
// Check if this module is the main module (handles symlinks properly)
|
|
1737
1955
|
const currentFile = fileURLToPath(import.meta.url);
|
|
@@ -1742,6 +1960,9 @@ if (mainFile &&
|
|
|
1742
1960
|
currentRealFile === mainFile ||
|
|
1743
1961
|
currentFile === realpathSync(mainFile))) {
|
|
1744
1962
|
main().catch((error) => {
|
|
1963
|
+
if (isPartialUploadError(error)) {
|
|
1964
|
+
process.exit(1);
|
|
1965
|
+
}
|
|
1745
1966
|
logger.error('Error:', error.message);
|
|
1746
1967
|
process.exit(1);
|
|
1747
1968
|
});
|