screenci 0.0.44 → 0.0.46
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 +77 -195
- package/dist/cli.d.ts +22 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +640 -767
- package/dist/cli.js.map +1 -1
- package/dist/docs/manifest.d.ts +529 -0
- package/dist/docs/manifest.d.ts.map +1 -0
- package/dist/docs/manifest.js +243 -0
- package/dist/docs/manifest.js.map +1 -0
- package/dist/docs/video-sources/assets-and-overlays.video.d.ts +2 -0
- package/dist/docs/video-sources/assets-and-overlays.video.d.ts.map +1 -0
- package/dist/docs/video-sources/assets-and-overlays.video.js +40 -0
- package/dist/docs/video-sources/assets-and-overlays.video.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 +24 -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 +94 -0
- package/dist/docs/videos.d.ts.map +1 -0
- package/dist/docs/videos.js +54 -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 +10 -9
- package/dist/src/asset.d.ts.map +1 -1
- package/dist/src/asset.js +7 -13
- 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 +44 -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 +13 -7
- 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/init.d.ts +12 -0
- package/dist/src/init.d.ts.map +1 -0
- package/dist/src/init.js +674 -0
- package/dist/src/init.js.map +1 -0
- 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 +42 -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 +8 -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
|
@@ -3,14 +3,114 @@ import { createReadStream } from 'fs';
|
|
|
3
3
|
import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync } from 'fs';
|
|
4
4
|
import { createHash } from 'crypto';
|
|
5
5
|
import { createServer } from 'http';
|
|
6
|
-
import { appendFile,
|
|
7
|
-
import { dirname, relative as pathRelative, resolve } from 'path';
|
|
6
|
+
import { appendFile, readdir, readFile, stat } from 'fs/promises';
|
|
7
|
+
import { delimiter, dirname, relative as pathRelative, resolve } from 'path';
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
9
9
|
import { Command, CommanderError } from 'commander';
|
|
10
|
-
import { input, confirm, select } from '@inquirer/prompts';
|
|
11
|
-
import ora from 'ora';
|
|
12
10
|
import pc from 'picocolors';
|
|
13
11
|
import { logger } from './src/logger.js';
|
|
12
|
+
import { determinePackageManager, parsePackageManager, runInit, } from './src/init.js';
|
|
13
|
+
import { SCREENCI_DISABLE_RECORDING_TIMINGS_ENV, SCREENCI_MOCK_RECORD_ENV, } from './src/runtimeMode.js';
|
|
14
|
+
import { DEFAULT_RECORD_UPLOAD_POLICY } from './src/defaults.js';
|
|
15
|
+
import { findDuplicateTitles, formatDuplicateTitlesMessage, } from './src/titleValidation.js';
|
|
16
|
+
const SCREENCI_MOCK_RECORD_DOCS_URL = 'https://screenci.com/docs/reference/cli/#--mock-record';
|
|
17
|
+
export function collectPlaywrightListTitles(suites) {
|
|
18
|
+
const titles = [];
|
|
19
|
+
const visitSuite = (suite) => {
|
|
20
|
+
for (const spec of suite.specs ?? []) {
|
|
21
|
+
titles.push(spec.title);
|
|
22
|
+
}
|
|
23
|
+
for (const child of suite.suites ?? []) {
|
|
24
|
+
visitSuite(child);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
for (const suite of suites) {
|
|
28
|
+
visitSuite(suite);
|
|
29
|
+
}
|
|
30
|
+
return titles;
|
|
31
|
+
}
|
|
32
|
+
function parsePlaywrightListReport(stdout) {
|
|
33
|
+
return JSON.parse(stdout);
|
|
34
|
+
}
|
|
35
|
+
async function collectDiscoveredTestTitles(configPath, additionalArgs, env) {
|
|
36
|
+
const listArgs = [
|
|
37
|
+
'test',
|
|
38
|
+
'--config',
|
|
39
|
+
configPath,
|
|
40
|
+
...additionalArgs,
|
|
41
|
+
'--list',
|
|
42
|
+
'--reporter=json',
|
|
43
|
+
];
|
|
44
|
+
const spawnSpec = resolveSpawnSpec('playwright', listArgs);
|
|
45
|
+
return await new Promise((resolve, reject) => {
|
|
46
|
+
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
47
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
48
|
+
...(spawnSpec.shell !== undefined ? { shell: spawnSpec.shell } : {}),
|
|
49
|
+
...(spawnSpec.windowsVerbatimArguments !== undefined
|
|
50
|
+
? {
|
|
51
|
+
windowsVerbatimArguments: spawnSpec.windowsVerbatimArguments,
|
|
52
|
+
}
|
|
53
|
+
: {}),
|
|
54
|
+
env,
|
|
55
|
+
});
|
|
56
|
+
const childSignals = forwardChildSignals(child, 'screenci title validation', {
|
|
57
|
+
killTree: process.platform !== 'win32',
|
|
58
|
+
exitParentOnForward: true,
|
|
59
|
+
});
|
|
60
|
+
let stdout = '';
|
|
61
|
+
let stderr = '';
|
|
62
|
+
child.stdout?.setEncoding?.('utf8');
|
|
63
|
+
child.stderr?.setEncoding?.('utf8');
|
|
64
|
+
child.stdout?.on('data', (chunk) => {
|
|
65
|
+
stdout += chunk;
|
|
66
|
+
});
|
|
67
|
+
child.stderr?.on('data', (chunk) => {
|
|
68
|
+
stderr += chunk;
|
|
69
|
+
});
|
|
70
|
+
child.on('close', (code, signal) => {
|
|
71
|
+
const forwardedSignal = childSignals.getForwardedSignal();
|
|
72
|
+
childSignals.cleanup();
|
|
73
|
+
if (forwardedSignal) {
|
|
74
|
+
process.kill(process.pid, forwardedSignal);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (signal) {
|
|
78
|
+
process.kill(process.pid, signal);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (code !== 0) {
|
|
82
|
+
if (stderr.trim() === '' && stdout.trim() === '') {
|
|
83
|
+
resolve([]);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
reject(new Error(stderr.trim() || stdout.trim() || 'Playwright test discovery failed'));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
if (stdout.trim() === '') {
|
|
91
|
+
resolve([]);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const report = parsePlaywrightListReport(stdout);
|
|
95
|
+
resolve(collectPlaywrightListTitles(report.suites ?? []));
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
reject(error);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
child.on('error', (err) => {
|
|
102
|
+
childSignals.cleanup();
|
|
103
|
+
reject(err);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
async function validateUniqueDiscoveredTestTitles(configPath, additionalArgs, env) {
|
|
108
|
+
const titles = await collectDiscoveredTestTitles(configPath, additionalArgs, env);
|
|
109
|
+
const duplicates = findDuplicateTitles(titles);
|
|
110
|
+
if (duplicates.length > 0) {
|
|
111
|
+
throw new Error(formatDuplicateTitlesMessage(duplicates));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
14
114
|
function resolveRecordingFileCandidates(filePath, configDir) {
|
|
15
115
|
return [
|
|
16
116
|
filePath,
|
|
@@ -50,11 +150,210 @@ class UploadCancelledError extends Error {
|
|
|
50
150
|
this.name = 'UploadCancelledError';
|
|
51
151
|
}
|
|
52
152
|
}
|
|
153
|
+
class PartialUploadError extends Error {
|
|
154
|
+
constructor(message = 'Not all recordings succeeded to upload.') {
|
|
155
|
+
super(message);
|
|
156
|
+
this.name = 'PartialUploadError';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
53
159
|
function isUploadCancelledError(err) {
|
|
54
160
|
return (err instanceof UploadCancelledError ||
|
|
55
161
|
(err instanceof Error &&
|
|
56
162
|
(err.name === 'AbortError' || err.name === 'UploadCancelledError')));
|
|
57
163
|
}
|
|
164
|
+
function isPartialUploadError(err) {
|
|
165
|
+
return err instanceof PartialUploadError;
|
|
166
|
+
}
|
|
167
|
+
function supportsInPlaceUploadUpdates(verbose) {
|
|
168
|
+
return !verbose && process.stdout.isTTY === true && !process.env.CI;
|
|
169
|
+
}
|
|
170
|
+
function formatUploadProgressLine(videoName, status) {
|
|
171
|
+
switch (status) {
|
|
172
|
+
case undefined:
|
|
173
|
+
return `${pc.cyan('...')} Uploading "${videoName}"`;
|
|
174
|
+
case 'success':
|
|
175
|
+
return `${pc.green('✔')} Uploaded "${videoName}"`;
|
|
176
|
+
case 'failure':
|
|
177
|
+
return `${pc.red('✖')} Failed to upload "${videoName}"`;
|
|
178
|
+
case 'cancelled':
|
|
179
|
+
return `${pc.yellow('!')} Cancelled "${videoName}"`;
|
|
180
|
+
default: {
|
|
181
|
+
const exhaustiveCheck = status;
|
|
182
|
+
return exhaustiveCheck;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function createUploadProgressReporter(videoNames, verbose) {
|
|
187
|
+
const useInPlaceUpdates = supportsInPlaceUploadUpdates(verbose);
|
|
188
|
+
if (!useInPlaceUpdates) {
|
|
189
|
+
if (videoNames.length > 1) {
|
|
190
|
+
logger.info(`Uploading ${videoNames.length} recordings in parallel...`);
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
complete(index, status) {
|
|
194
|
+
logger.info(formatUploadProgressLine(videoNames[index] ?? 'unknown', status));
|
|
195
|
+
},
|
|
196
|
+
info(message) {
|
|
197
|
+
logger.info(message);
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const statuses = new Array(videoNames.length);
|
|
202
|
+
let hasRendered = false;
|
|
203
|
+
const render = () => {
|
|
204
|
+
const renderedLines = videoNames.map((videoName, index) => formatUploadProgressLine(videoName, statuses[index]));
|
|
205
|
+
process.stdout.write(`${hasRendered && videoNames.length > 0 ? `\u001B[${videoNames.length}A` : ''}${renderedLines.map((line) => `\r\u001B[2K${line}`).join('\n')}\n`);
|
|
206
|
+
hasRendered = true;
|
|
207
|
+
};
|
|
208
|
+
const clear = () => {
|
|
209
|
+
if (!hasRendered || videoNames.length === 0)
|
|
210
|
+
return;
|
|
211
|
+
process.stdout.write(`\u001B[${videoNames.length}A${Array.from({ length: videoNames.length }, () => '\r\u001B[2K').join('\n')}\n`);
|
|
212
|
+
hasRendered = false;
|
|
213
|
+
};
|
|
214
|
+
render();
|
|
215
|
+
return {
|
|
216
|
+
complete(index, status) {
|
|
217
|
+
statuses[index] = status;
|
|
218
|
+
render();
|
|
219
|
+
},
|
|
220
|
+
info(message) {
|
|
221
|
+
clear();
|
|
222
|
+
logger.info(message);
|
|
223
|
+
render();
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
async function loadUploadCandidate(screenciDir, entry, verbose) {
|
|
228
|
+
const dataJsonPath = resolve(screenciDir, entry, 'data.json');
|
|
229
|
+
if (!existsSync(dataJsonPath)) {
|
|
230
|
+
if (verbose)
|
|
231
|
+
logger.info(`Skipping "${entry}": no data.json found`);
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
let data;
|
|
235
|
+
try {
|
|
236
|
+
const raw = await readFile(dataJsonPath, 'utf-8');
|
|
237
|
+
data = JSON.parse(raw);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
logger.warn(`Failed to read ${dataJsonPath}, skipping`);
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
const videoName = data.metadata?.videoName ?? entry;
|
|
244
|
+
const preparedUploadAssets = await collectUploadAssets(data, resolve(screenciDir, '..'));
|
|
245
|
+
return {
|
|
246
|
+
entry,
|
|
247
|
+
videoName,
|
|
248
|
+
data: annotateRecordingDataWithAssetHashes(data, preparedUploadAssets),
|
|
249
|
+
preparedUploadAssets,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
async function uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, verbose, uploadAbort, progressReporter, progressIndex) {
|
|
253
|
+
const { entry, videoName, data, preparedUploadAssets } = candidate;
|
|
254
|
+
try {
|
|
255
|
+
uploadAbort.throwIfAborted();
|
|
256
|
+
const recordingPath = resolve(screenciDir, entry, 'recording.mp4');
|
|
257
|
+
const recordingHash = existsSync(recordingPath)
|
|
258
|
+
? await hashFile(recordingPath)
|
|
259
|
+
: undefined;
|
|
260
|
+
const startResponse = await fetch(`${apiUrl}/cli/upload/start`, {
|
|
261
|
+
method: 'POST',
|
|
262
|
+
headers: {
|
|
263
|
+
'Content-Type': 'application/json',
|
|
264
|
+
'X-ScreenCI-Secret': secret,
|
|
265
|
+
},
|
|
266
|
+
body: JSON.stringify({
|
|
267
|
+
projectName,
|
|
268
|
+
videoName,
|
|
269
|
+
data,
|
|
270
|
+
...(recordingHash !== undefined ? { recordingHash } : {}),
|
|
271
|
+
expectedAssets: preparedUploadAssets.map((asset) => ({
|
|
272
|
+
fileHash: asset.fileHash,
|
|
273
|
+
size: asset.size,
|
|
274
|
+
path: asset.path,
|
|
275
|
+
...(typeof asset.contentType === 'string'
|
|
276
|
+
? { contentType: asset.contentType }
|
|
277
|
+
: {}),
|
|
278
|
+
...(typeof asset.name === 'string' ? { name: asset.name } : {}),
|
|
279
|
+
})),
|
|
280
|
+
}),
|
|
281
|
+
signal: uploadAbort.signal,
|
|
282
|
+
});
|
|
283
|
+
if (!startResponse.ok) {
|
|
284
|
+
const text = await startResponse.text();
|
|
285
|
+
progressReporter.complete(progressIndex, 'failure');
|
|
286
|
+
return {
|
|
287
|
+
projectId: null,
|
|
288
|
+
hadFailure: true,
|
|
289
|
+
videoName,
|
|
290
|
+
failureMessage: formatUploadStartFailureMessage(videoName, startResponse.status, text, secret),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const { recordingId, projectId } = (await startResponse.json());
|
|
294
|
+
if (verbose) {
|
|
295
|
+
logger.info(`recordingId=${recordingId} projectId=${projectId}`);
|
|
296
|
+
logger.info(`assets=${preparedUploadAssets.length} recordingHash=${recordingHash ?? 'none'}`);
|
|
297
|
+
}
|
|
298
|
+
await uploadAssets(preparedUploadAssets, apiUrl, secret, recordingId, uploadAbort.signal, uploadAbort.throwIfAborted, progressReporter);
|
|
299
|
+
if (existsSync(recordingPath)) {
|
|
300
|
+
uploadAbort.throwIfAborted();
|
|
301
|
+
const fileStat = await stat(recordingPath);
|
|
302
|
+
if (verbose) {
|
|
303
|
+
logger.info(`Uploading recording.mp4 size=${(fileStat.size / 1024 / 1024).toFixed(1)}MB`);
|
|
304
|
+
}
|
|
305
|
+
const stream = createReadStream(recordingPath);
|
|
306
|
+
const abortStream = () => {
|
|
307
|
+
stream.destroy(new UploadCancelledError(`Upload cancelled for "${videoName}"`));
|
|
308
|
+
};
|
|
309
|
+
uploadAbort.signal.addEventListener('abort', abortStream, {
|
|
310
|
+
once: true,
|
|
311
|
+
});
|
|
312
|
+
try {
|
|
313
|
+
const recordingResponse = await fetch(`${apiUrl}/cli/upload/${recordingId}/recording`, {
|
|
314
|
+
method: 'PUT',
|
|
315
|
+
headers: {
|
|
316
|
+
'Content-Type': 'video/mp4',
|
|
317
|
+
'Content-Length': String(fileStat.size),
|
|
318
|
+
'X-ScreenCI-Secret': secret,
|
|
319
|
+
},
|
|
320
|
+
body: stream,
|
|
321
|
+
signal: uploadAbort.signal,
|
|
322
|
+
// @ts-expect-error Node.js fetch supports duplex for streaming
|
|
323
|
+
duplex: 'half',
|
|
324
|
+
});
|
|
325
|
+
if (!recordingResponse.ok) {
|
|
326
|
+
const text = await recordingResponse.text();
|
|
327
|
+
progressReporter.complete(progressIndex, 'failure');
|
|
328
|
+
return {
|
|
329
|
+
projectId,
|
|
330
|
+
hadFailure: true,
|
|
331
|
+
videoName,
|
|
332
|
+
failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
uploadAbort.signal.removeEventListener('abort', abortStream);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
progressReporter.complete(progressIndex, 'success');
|
|
341
|
+
return { projectId, hadFailure: false, videoName };
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
if (isUploadCancelledError(err)) {
|
|
345
|
+
progressReporter.complete(progressIndex, 'cancelled');
|
|
346
|
+
throw err;
|
|
347
|
+
}
|
|
348
|
+
progressReporter.complete(progressIndex, 'failure');
|
|
349
|
+
return {
|
|
350
|
+
projectId: null,
|
|
351
|
+
hadFailure: true,
|
|
352
|
+
videoName,
|
|
353
|
+
failureMessage: `Network error uploading "${videoName}": ${err instanceof Error ? err.message : String(err)}`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
}
|
|
58
357
|
export function attachUploadAbortStdinListener(input, onAbort) {
|
|
59
358
|
const handleStdinData = (chunk) => {
|
|
60
359
|
const bytes = typeof chunk === 'string'
|
|
@@ -106,63 +405,75 @@ function createUploadAbortController(activityLabel) {
|
|
|
106
405
|
cleanup,
|
|
107
406
|
};
|
|
108
407
|
}
|
|
109
|
-
function quoteWindowsCommandArg(arg) {
|
|
110
|
-
if (arg.length === 0)
|
|
111
|
-
return '""';
|
|
112
|
-
if (/^[A-Za-z0-9_./:\\=-]+$/.test(arg))
|
|
113
|
-
return arg;
|
|
114
|
-
return `"${arg.replace(/"/g, '""')}"`;
|
|
115
|
-
}
|
|
116
408
|
function resolveSpawnSpec(cmd, args) {
|
|
117
409
|
if (process.platform !== 'win32') {
|
|
118
410
|
return { command: cmd, args };
|
|
119
411
|
}
|
|
120
|
-
const
|
|
121
|
-
if (!
|
|
412
|
+
const windowsCmdShims = new Set(['npm', 'npx', 'playwright', 'pnpm']);
|
|
413
|
+
if (!windowsCmdShims.has(cmd)) {
|
|
122
414
|
return { command: cmd, args };
|
|
123
415
|
}
|
|
124
|
-
const commandLine = [cmd, ...args].map(quoteWindowsCommandArg).join(' ');
|
|
125
416
|
return {
|
|
126
|
-
command: 'cmd',
|
|
127
|
-
args: ['/d', '/c',
|
|
417
|
+
command: process.env.comspec ?? 'cmd.exe',
|
|
418
|
+
args: ['/d', '/s', '/c', `"${buildWindowsBatchCommandLine(cmd, args)}"`],
|
|
419
|
+
windowsVerbatimArguments: true,
|
|
128
420
|
};
|
|
129
421
|
}
|
|
130
|
-
function
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
}
|
|
422
|
+
function quoteWindowsBatchArg(arg) {
|
|
423
|
+
if (arg.length === 0) {
|
|
424
|
+
return '""';
|
|
425
|
+
}
|
|
426
|
+
return `"${arg
|
|
427
|
+
.replace(/(\\*)"/g, '$1$1\\"')
|
|
428
|
+
.replace(/(\\+)$/g, '$1$1')
|
|
429
|
+
.replace(/%/g, '%%')}"`;
|
|
430
|
+
}
|
|
431
|
+
function buildWindowsBatchCommandLine(cmd, args) {
|
|
432
|
+
return [resolveWindowsCmdShim(cmd), ...args]
|
|
433
|
+
.map(quoteWindowsBatchArg)
|
|
434
|
+
.join(' ');
|
|
435
|
+
}
|
|
436
|
+
function resolveWindowsCmdShim(cmd) {
|
|
437
|
+
const shimName = `${cmd}.cmd`;
|
|
438
|
+
const pathEntries = process.env.PATH?.split(delimiter) ?? [];
|
|
439
|
+
for (const entry of pathEntries) {
|
|
440
|
+
if (!entry)
|
|
441
|
+
continue;
|
|
442
|
+
const shimPath = resolve(entry, shimName);
|
|
443
|
+
if (existsSync(shimPath)) {
|
|
444
|
+
return shimPath;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const bundledShimCommands = new Set(['npm', 'npx']);
|
|
448
|
+
if (bundledShimCommands.has(cmd)) {
|
|
449
|
+
const bundledShimPath = resolve(dirname(process.execPath), shimName);
|
|
450
|
+
if (existsSync(bundledShimPath)) {
|
|
451
|
+
return bundledShimPath;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return shimName;
|
|
162
455
|
}
|
|
163
|
-
function forwardChildSignals(child, activityLabel) {
|
|
456
|
+
function forwardChildSignals(child, activityLabel, options = {}) {
|
|
164
457
|
let forwardedSignal = null;
|
|
165
458
|
let forceKillTimer = null;
|
|
459
|
+
const killTree = options.killTree ?? false;
|
|
460
|
+
const exitParentOnForward = options.exitParentOnForward ?? false;
|
|
461
|
+
const killChild = (signal) => {
|
|
462
|
+
if (child.pid === undefined)
|
|
463
|
+
return;
|
|
464
|
+
if (killTree && process.platform !== 'win32') {
|
|
465
|
+
try {
|
|
466
|
+
process.kill(-child.pid, signal);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
// Fall back to direct child kill below.
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (!child.killed) {
|
|
474
|
+
child.kill(signal);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
166
477
|
const forwardSignal = (signal) => {
|
|
167
478
|
if (forwardedSignal !== null)
|
|
168
479
|
return;
|
|
@@ -170,31 +481,42 @@ function forwardChildSignals(child, activityLabel) {
|
|
|
170
481
|
if (process.env.SCREENCI_SIGNAL_LOGGING !== 'silent') {
|
|
171
482
|
logger.info(`Received ${signal}, stopping ${activityLabel}...`);
|
|
172
483
|
}
|
|
173
|
-
|
|
174
|
-
|
|
484
|
+
killChild(signal);
|
|
485
|
+
if (exitParentOnForward) {
|
|
486
|
+
cleanup();
|
|
487
|
+
process.exit(signal === 'SIGINT' ? 130 : 143);
|
|
175
488
|
}
|
|
176
489
|
forceKillTimer = setTimeout(() => {
|
|
177
490
|
if (child.exitCode === null) {
|
|
178
491
|
if (process.env.SCREENCI_SIGNAL_LOGGING !== 'silent') {
|
|
179
492
|
logger.info(`Forcing ${activityLabel} to stop after timeout...`);
|
|
180
493
|
}
|
|
181
|
-
|
|
494
|
+
killChild('SIGKILL');
|
|
495
|
+
process.exit(signal === 'SIGINT' ? 130 : 143);
|
|
182
496
|
}
|
|
183
497
|
}, 3000);
|
|
184
498
|
forceKillTimer.unref();
|
|
185
499
|
};
|
|
186
500
|
const handleSigint = () => forwardSignal('SIGINT');
|
|
187
501
|
const handleSigterm = () => forwardSignal('SIGTERM');
|
|
502
|
+
const cleanupStdinListener = attachUploadAbortStdinListener(process.stdin, (signal) => {
|
|
503
|
+
if (process.env.SCREENCI_SIGNAL_LOGGING !== 'silent') {
|
|
504
|
+
logger.info(`Received ${signal}, stopping ${activityLabel}...`);
|
|
505
|
+
}
|
|
506
|
+
forwardSignal(signal);
|
|
507
|
+
});
|
|
508
|
+
const cleanup = () => {
|
|
509
|
+
if (forceKillTimer !== null) {
|
|
510
|
+
clearTimeout(forceKillTimer);
|
|
511
|
+
}
|
|
512
|
+
process.off('SIGINT', handleSigint);
|
|
513
|
+
process.off('SIGTERM', handleSigterm);
|
|
514
|
+
cleanupStdinListener();
|
|
515
|
+
};
|
|
188
516
|
process.on('SIGINT', handleSigint);
|
|
189
517
|
process.on('SIGTERM', handleSigterm);
|
|
190
518
|
return {
|
|
191
|
-
cleanup
|
|
192
|
-
if (forceKillTimer !== null) {
|
|
193
|
-
clearTimeout(forceKillTimer);
|
|
194
|
-
}
|
|
195
|
-
process.off('SIGINT', handleSigint);
|
|
196
|
-
process.off('SIGTERM', handleSigterm);
|
|
197
|
-
},
|
|
519
|
+
cleanup,
|
|
198
520
|
getForwardedSignal: () => forwardedSignal,
|
|
199
521
|
};
|
|
200
522
|
}
|
|
@@ -219,46 +541,6 @@ function findScreenCIConfig(customPath) {
|
|
|
219
541
|
}
|
|
220
542
|
return null;
|
|
221
543
|
}
|
|
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
544
|
async function hashFile(filePath) {
|
|
263
545
|
return new Promise((resolveHash, reject) => {
|
|
264
546
|
const hash = createHash('sha256');
|
|
@@ -494,6 +776,16 @@ export function formatUploadStartFailureMessage(videoName, status, responseText,
|
|
|
494
776
|
}
|
|
495
777
|
return `Failed to start upload for "${videoName}": ${status}${hint401(status, secret)}`;
|
|
496
778
|
}
|
|
779
|
+
const EXPRESSIVE_TIER_ERROR_PREFIX = 'Expressive narration and style prompts require the Business tier.';
|
|
780
|
+
export function formatFailedVideoMessage(videoName, message) {
|
|
781
|
+
if (message.startsWith(EXPRESSIVE_TIER_ERROR_PREFIX)) {
|
|
782
|
+
return [
|
|
783
|
+
`${videoName}: ${message}`,
|
|
784
|
+
"If you want to keep using the current tier, remove `voice.style` or `modelType: 'expressive'` from `createNarration()`.",
|
|
785
|
+
].join('\n');
|
|
786
|
+
}
|
|
787
|
+
return `${videoName}: ${message}`;
|
|
788
|
+
}
|
|
497
789
|
export function printUploadStartFailureMessage(videoName, status, responseText, secret) {
|
|
498
790
|
const message = formatUploadStartFailureMessage(videoName, status, responseText, secret);
|
|
499
791
|
if (responseText.trim().length > 0) {
|
|
@@ -502,7 +794,15 @@ export function printUploadStartFailureMessage(videoName, status, responseText,
|
|
|
502
794
|
}
|
|
503
795
|
logger.warn(message);
|
|
504
796
|
}
|
|
505
|
-
async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIfAborted) {
|
|
797
|
+
async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIfAborted, progressReporter) {
|
|
798
|
+
const logInfo = (message) => {
|
|
799
|
+
if (progressReporter) {
|
|
800
|
+
progressReporter.info(message);
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
logger.info(message);
|
|
804
|
+
}
|
|
805
|
+
};
|
|
506
806
|
for (const asset of assets) {
|
|
507
807
|
throwIfAborted();
|
|
508
808
|
try {
|
|
@@ -528,7 +828,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
528
828
|
}
|
|
529
829
|
const checkBody = (await checkRes.json());
|
|
530
830
|
if (checkBody.exists) {
|
|
531
|
-
|
|
831
|
+
logInfo(`Asset already exists: ${asset.path}`);
|
|
532
832
|
continue;
|
|
533
833
|
}
|
|
534
834
|
if (!asset.fileBuffer || !asset.contentType) {
|
|
@@ -555,14 +855,14 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
555
855
|
if (!res.ok) {
|
|
556
856
|
const text = await res.text();
|
|
557
857
|
if (res.status === 409 && text.includes('already exists')) {
|
|
558
|
-
|
|
858
|
+
logInfo(`Asset already exists: ${asset.path}`);
|
|
559
859
|
}
|
|
560
860
|
else {
|
|
561
861
|
logger.warn(`Failed to upload asset ${asset.path}: ${res.status} ${text}${hint401(res.status, secret)}`);
|
|
562
862
|
}
|
|
563
863
|
}
|
|
564
864
|
else {
|
|
565
|
-
|
|
865
|
+
logInfo(`Asset uploaded: ${asset.path}`);
|
|
566
866
|
}
|
|
567
867
|
}
|
|
568
868
|
catch (err) {
|
|
@@ -573,7 +873,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
573
873
|
}
|
|
574
874
|
}
|
|
575
875
|
}
|
|
576
|
-
async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specificEntry, verbose = false) {
|
|
876
|
+
export async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specificEntry, verbose = false) {
|
|
577
877
|
const uploadAbort = createUploadAbortController('upload');
|
|
578
878
|
let entries;
|
|
579
879
|
try {
|
|
@@ -581,135 +881,62 @@ async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specif
|
|
|
581
881
|
}
|
|
582
882
|
catch {
|
|
583
883
|
logger.warn('No .screenci directory found, skipping upload');
|
|
584
|
-
return
|
|
884
|
+
return {
|
|
885
|
+
projectId: null,
|
|
886
|
+
hadFailures: false,
|
|
887
|
+
failedVideoNames: [],
|
|
888
|
+
failedVideoMessages: [],
|
|
889
|
+
};
|
|
585
890
|
}
|
|
586
891
|
if (specificEntry !== undefined) {
|
|
587
892
|
entries = entries.filter((e) => e === specificEntry);
|
|
588
893
|
}
|
|
589
894
|
let firstProjectId = null;
|
|
590
895
|
try {
|
|
591
|
-
|
|
896
|
+
const candidates = (await Promise.all(entries.map(async (entry) => {
|
|
592
897
|
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
|
-
}
|
|
898
|
+
return await loadUploadCandidate(screenciDir, entry, verbose);
|
|
899
|
+
}))).filter((candidate) => candidate !== null);
|
|
900
|
+
if (candidates.length === 0) {
|
|
901
|
+
return {
|
|
902
|
+
projectId: null,
|
|
903
|
+
hadFailures: false,
|
|
904
|
+
failedVideoNames: [],
|
|
905
|
+
failedVideoMessages: [],
|
|
906
|
+
};
|
|
706
907
|
}
|
|
707
|
-
|
|
908
|
+
const progressReporter = createUploadProgressReporter(candidates.map((candidate) => candidate.videoName), verbose);
|
|
909
|
+
const results = await Promise.all(candidates.map(async (candidate, index) => await uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, verbose, uploadAbort, progressReporter, index)));
|
|
910
|
+
firstProjectId =
|
|
911
|
+
results.find((result) => result.projectId !== null)?.projectId ?? null;
|
|
912
|
+
const hadFailures = results.some((result) => result.hadFailure);
|
|
913
|
+
const failedVideoNames = results
|
|
914
|
+
.filter((result) => result.hadFailure)
|
|
915
|
+
.map((result) => result.videoName);
|
|
916
|
+
const failedVideoMessages = results.flatMap((result) => result.hadFailure && typeof result.failureMessage === 'string'
|
|
917
|
+
? [{ videoName: result.videoName, message: result.failureMessage }]
|
|
918
|
+
: []);
|
|
919
|
+
return {
|
|
920
|
+
projectId: firstProjectId,
|
|
921
|
+
hadFailures,
|
|
922
|
+
failedVideoNames,
|
|
923
|
+
failedVideoMessages,
|
|
924
|
+
};
|
|
708
925
|
}
|
|
709
926
|
finally {
|
|
710
927
|
uploadAbort.cleanup();
|
|
711
928
|
}
|
|
712
929
|
}
|
|
930
|
+
async function countCompletedRecordings(screenciDir) {
|
|
931
|
+
let entries;
|
|
932
|
+
try {
|
|
933
|
+
entries = await readdir(screenciDir);
|
|
934
|
+
}
|
|
935
|
+
catch {
|
|
936
|
+
return 0;
|
|
937
|
+
}
|
|
938
|
+
return entries.filter((entry) => existsSync(resolve(screenciDir, entry, 'data.json'))).length;
|
|
939
|
+
}
|
|
713
940
|
export function getDevBackendUrl() {
|
|
714
941
|
const devBackendPort = process.env.DEV_BACKEND_PORT;
|
|
715
942
|
return devBackendPort
|
|
@@ -728,40 +955,6 @@ async function writeGitHubProjectOutput(projectUrl) {
|
|
|
728
955
|
return;
|
|
729
956
|
await appendFile(githubOutput, `screenci_project_url=${projectUrl}\n`);
|
|
730
957
|
}
|
|
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
958
|
async function loadScreenCIConfigAndEnv(configPath) {
|
|
766
959
|
const resolvedConfigPath = findScreenCIConfig(configPath);
|
|
767
960
|
if (!resolvedConfigPath) {
|
|
@@ -815,6 +1008,17 @@ async function loadEnvFileFromConfigSource(resolvedConfigPath, warnOnFailure) {
|
|
|
815
1008
|
// the existing process env; Playwright will still load the config normally.
|
|
816
1009
|
}
|
|
817
1010
|
}
|
|
1011
|
+
async function resolveConfiguredEnvFilePath(resolvedConfigPath) {
|
|
1012
|
+
try {
|
|
1013
|
+
const screenciConfig = await tryReadConfigFromSource(resolvedConfigPath);
|
|
1014
|
+
if (!screenciConfig.envFile)
|
|
1015
|
+
return undefined;
|
|
1016
|
+
return resolve(dirname(resolvedConfigPath), screenciConfig.envFile);
|
|
1017
|
+
}
|
|
1018
|
+
catch {
|
|
1019
|
+
return undefined;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
818
1022
|
export function extractConfigStringLiteral(configSource, property) {
|
|
819
1023
|
const singleQuoteMatch = configSource.match(new RegExp(property + "\\s*:\\s*'([^'\\n]+)'"));
|
|
820
1024
|
if (singleQuoteMatch)
|
|
@@ -825,6 +1029,27 @@ export function extractConfigStringLiteral(configSource, property) {
|
|
|
825
1029
|
const templateLiteralMatch = configSource.match(new RegExp(property + '\\s*:\\s*`([^`\\n]+)`'));
|
|
826
1030
|
return templateLiteralMatch?.[1];
|
|
827
1031
|
}
|
|
1032
|
+
export function extractRecordUploadPolicyLiteral(configSource) {
|
|
1033
|
+
const singleQuoteMatch = configSource.match(/record\s*:\s*\{[\s\S]*?upload\s*:\s*'(passed-only|all-or-nothing)'/);
|
|
1034
|
+
if (singleQuoteMatch) {
|
|
1035
|
+
return singleQuoteMatch[1];
|
|
1036
|
+
}
|
|
1037
|
+
const doubleQuoteMatch = configSource.match(/record\s*:\s*\{[\s\S]*?upload\s*:\s*"(passed-only|all-or-nothing)"/);
|
|
1038
|
+
if (doubleQuoteMatch) {
|
|
1039
|
+
return doubleQuoteMatch[1];
|
|
1040
|
+
}
|
|
1041
|
+
const templateLiteralMatch = configSource.match(/record\s*:\s*\{[\s\S]*?upload\s*:\s*`(passed-only|all-or-nothing)`/);
|
|
1042
|
+
return templateLiteralMatch?.[1];
|
|
1043
|
+
}
|
|
1044
|
+
export function extractMockRecordLiteral(configSource) {
|
|
1045
|
+
const match = configSource.match(/test\s*:\s*\{[\s\S]*?mockRecord\s*:\s*(true|false)/);
|
|
1046
|
+
if (!match)
|
|
1047
|
+
return undefined;
|
|
1048
|
+
return match[1] === 'true';
|
|
1049
|
+
}
|
|
1050
|
+
function resolveRecordUploadPolicy(config) {
|
|
1051
|
+
return config.record?.upload ?? DEFAULT_RECORD_UPLOAD_POLICY;
|
|
1052
|
+
}
|
|
828
1053
|
async function tryReadConfigFromSource(resolvedConfigPath) {
|
|
829
1054
|
const configSource = await readFile(resolvedConfigPath, 'utf-8');
|
|
830
1055
|
const projectName = extractConfigStringLiteral(configSource, 'projectName');
|
|
@@ -832,9 +1057,13 @@ async function tryReadConfigFromSource(resolvedConfigPath) {
|
|
|
832
1057
|
throw new Error('Could not determine projectName from screenci.config.ts without importing it.');
|
|
833
1058
|
}
|
|
834
1059
|
const envFile = extractConfigStringLiteral(configSource, 'envFile');
|
|
1060
|
+
const recordUpload = extractRecordUploadPolicyLiteral(configSource);
|
|
1061
|
+
const mockRecord = extractMockRecordLiteral(configSource);
|
|
835
1062
|
return {
|
|
836
1063
|
projectName,
|
|
837
1064
|
...(envFile !== undefined ? { envFile } : {}),
|
|
1065
|
+
...(recordUpload !== undefined ? { record: { upload: recordUpload } } : {}),
|
|
1066
|
+
...(mockRecord !== undefined ? { test: { mockRecord } } : {}),
|
|
838
1067
|
};
|
|
839
1068
|
}
|
|
840
1069
|
export function getConfigModuleSpecifier(resolvedConfigPath) {
|
|
@@ -850,11 +1079,17 @@ async function loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath) {
|
|
|
850
1079
|
return configModule.default;
|
|
851
1080
|
}
|
|
852
1081
|
catch (err) {
|
|
853
|
-
|
|
854
|
-
err.message.includes('Requiring @playwright/test second time')
|
|
1082
|
+
const hasPlaywrightCollision = err instanceof Error &&
|
|
1083
|
+
err.message.includes('Requiring @playwright/test second time');
|
|
1084
|
+
if (hasPlaywrightCollision) {
|
|
855
1085
|
logger.warn('Playwright was loaded from multiple module paths. Falling back to static config parsing for upload metadata.');
|
|
1086
|
+
}
|
|
1087
|
+
try {
|
|
856
1088
|
return (await tryReadConfigFromSource(resolvedConfigPath));
|
|
857
1089
|
}
|
|
1090
|
+
catch {
|
|
1091
|
+
// Preserve the original import error when static parsing cannot recover.
|
|
1092
|
+
}
|
|
858
1093
|
throw err;
|
|
859
1094
|
}
|
|
860
1095
|
}
|
|
@@ -906,174 +1141,6 @@ async function updateVideoVisibility(videoId, isPublic, configPath) {
|
|
|
906
1141
|
}
|
|
907
1142
|
logger.info(`${isPublic ? 'Made public' : 'Made private'}: ${videoId}`);
|
|
908
1143
|
}
|
|
909
|
-
function generateConfig(projectName) {
|
|
910
|
-
return `import { defineConfig } from 'screenci'
|
|
911
|
-
|
|
912
|
-
export default defineConfig({
|
|
913
|
-
projectName: ${JSON.stringify(projectName)},
|
|
914
|
-
envFile: '.env',
|
|
915
|
-
videoDir: './videos',
|
|
916
|
-
use: {
|
|
917
|
-
recordOptions: {
|
|
918
|
-
aspectRatio: '16:9',
|
|
919
|
-
quality: '1080p',
|
|
920
|
-
fps: 30,
|
|
921
|
-
},
|
|
922
|
-
},
|
|
923
|
-
projects: [
|
|
924
|
-
{
|
|
925
|
-
name: 'chromium',
|
|
926
|
-
},
|
|
927
|
-
],
|
|
928
|
-
})
|
|
929
|
-
`;
|
|
930
|
-
}
|
|
931
|
-
function generatePackageJson(includePlaywrightCli = false, screenciDependency = 'latest') {
|
|
932
|
-
const devDependencies = {};
|
|
933
|
-
if (includePlaywrightCli) {
|
|
934
|
-
devDependencies['@playwright/cli'] = 'latest';
|
|
935
|
-
}
|
|
936
|
-
return (JSON.stringify({
|
|
937
|
-
type: 'module',
|
|
938
|
-
scripts: {
|
|
939
|
-
record: 'screenci record',
|
|
940
|
-
retry: 'screenci retry',
|
|
941
|
-
test: 'screenci test',
|
|
942
|
-
},
|
|
943
|
-
dependencies: {
|
|
944
|
-
screenci: screenciDependency,
|
|
945
|
-
'@playwright/test': '^1.59.0',
|
|
946
|
-
},
|
|
947
|
-
devDependencies,
|
|
948
|
-
}, null, 2) + '\n');
|
|
949
|
-
}
|
|
950
|
-
async function readCurrentScreenciVersion() {
|
|
951
|
-
const currentFileDir = dirname(fileURLToPath(import.meta.url));
|
|
952
|
-
const packageJsonPaths = [
|
|
953
|
-
resolve(currentFileDir, 'package.json'),
|
|
954
|
-
resolve(currentFileDir, '../package.json'),
|
|
955
|
-
];
|
|
956
|
-
for (const packageJsonPath of packageJsonPaths) {
|
|
957
|
-
try {
|
|
958
|
-
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf-8'));
|
|
959
|
-
if (typeof packageJson.version === 'string') {
|
|
960
|
-
return packageJson.version;
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
catch {
|
|
964
|
-
// Try the next candidate path.
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
return 'latest';
|
|
968
|
-
}
|
|
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
|
-
function generateReadme(projectName) {
|
|
984
|
-
return `# ${projectName}
|
|
985
|
-
|
|
986
|
-
This project uses ScreenCI + Playwright to create and upload polished product videos.
|
|
987
|
-
|
|
988
|
-
## How video recording works
|
|
989
|
-
|
|
990
|
-
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
|
-
|
|
992
|
-
Learn more: https://screenci.com/docs/intro/
|
|
993
|
-
|
|
994
|
-
## Quick start
|
|
995
|
-
|
|
996
|
-
1. Create your own videos in \`videos/*.video.ts\`, either manually or with an AI agent using your source code or a URL.
|
|
997
|
-
|
|
998
|
-
2. Run videos locally to test the script working:
|
|
999
|
-
|
|
1000
|
-
\`npx screenci test\` or with UI mode: \`npx screenci test --ui\`
|
|
1001
|
-
|
|
1002
|
-
3. Record videos:
|
|
1003
|
-
|
|
1004
|
-
\`npx screenci record\`
|
|
1005
|
-
|
|
1006
|
-
4. View results on screenci.com and optionally enable a public URL to embed the video on your site.
|
|
1007
|
-
`;
|
|
1008
|
-
}
|
|
1009
|
-
function generateGitignore() {
|
|
1010
|
-
return `/playwright-report/
|
|
1011
|
-
.screenci
|
|
1012
|
-
.playwright-cli/
|
|
1013
|
-
node_modules/
|
|
1014
|
-
.env
|
|
1015
|
-
`;
|
|
1016
|
-
}
|
|
1017
|
-
function generateGithubAction(workingDirectory) {
|
|
1018
|
-
const packageLockPath = workingDirectory === '.'
|
|
1019
|
-
? 'package-lock.json'
|
|
1020
|
-
: `${workingDirectory}/package-lock.json`;
|
|
1021
|
-
const envFilePath = workingDirectory === '.' ? './.env' : `./${workingDirectory}/.env`;
|
|
1022
|
-
return `name: ScreenCI
|
|
1023
|
-
|
|
1024
|
-
on:
|
|
1025
|
-
push:
|
|
1026
|
-
branches: [main]
|
|
1027
|
-
workflow_dispatch:
|
|
1028
|
-
|
|
1029
|
-
jobs:
|
|
1030
|
-
record:
|
|
1031
|
-
runs-on: ubuntu-latest
|
|
1032
|
-
environment:
|
|
1033
|
-
name: screenci
|
|
1034
|
-
url: \${{ steps.record.outputs.screenci_project_url }}
|
|
1035
|
-
steps:
|
|
1036
|
-
- name: Check SCREENCI_SECRET
|
|
1037
|
-
env:
|
|
1038
|
-
SCREENCI_SECRET: \${{ secrets.SCREENCI_SECRET }}
|
|
1039
|
-
run: |
|
|
1040
|
-
if [ -z "$SCREENCI_SECRET" ]; then
|
|
1041
|
-
echo "::error::SCREENCI_SECRET is not set. Copy it from https://app.screenci.com/secrets or ${envFilePath}, add it under Settings → Secrets and variables → Actions → Repository secrets, and then rerun this action."
|
|
1042
|
-
exit 1
|
|
1043
|
-
fi
|
|
1044
|
-
|
|
1045
|
-
- uses: actions/checkout@v4
|
|
1046
|
-
|
|
1047
|
-
- uses: actions/setup-node@v4
|
|
1048
|
-
with:
|
|
1049
|
-
node-version: 24
|
|
1050
|
-
cache: npm
|
|
1051
|
-
cache-dependency-path: ${packageLockPath}
|
|
1052
|
-
|
|
1053
|
-
- name: Install dependencies
|
|
1054
|
-
working-directory: ${workingDirectory}
|
|
1055
|
-
run: npm ci
|
|
1056
|
-
|
|
1057
|
-
- name: Cache Playwright Chromium
|
|
1058
|
-
uses: actions/cache@v5
|
|
1059
|
-
id: pw-cache
|
|
1060
|
-
with:
|
|
1061
|
-
path: ~/.cache/ms-playwright
|
|
1062
|
-
key: playwright-\${{ runner.os }}-\${{ hashFiles('${packageLockPath}') }}
|
|
1063
|
-
|
|
1064
|
-
- name: Install Chromium
|
|
1065
|
-
if: steps.pw-cache.outputs.cache-hit != 'true'
|
|
1066
|
-
working-directory: ${workingDirectory}
|
|
1067
|
-
run: npx playwright install chromium --with-deps
|
|
1068
|
-
|
|
1069
|
-
- id: record
|
|
1070
|
-
name: Record
|
|
1071
|
-
working-directory: ${workingDirectory}
|
|
1072
|
-
env:
|
|
1073
|
-
SCREENCI_SECRET: \${{ secrets.SCREENCI_SECRET }}
|
|
1074
|
-
run: npm run record
|
|
1075
|
-
`;
|
|
1076
|
-
}
|
|
1077
1144
|
function openBrowser(url) {
|
|
1078
1145
|
try {
|
|
1079
1146
|
if (process.platform === 'win32') {
|
|
@@ -1133,95 +1200,7 @@ async function performBrowserLogin(appUrl) {
|
|
|
1133
1200
|
server.on('close', () => clearTimeout(timeout));
|
|
1134
1201
|
});
|
|
1135
1202
|
}
|
|
1136
|
-
function
|
|
1137
|
-
return `import { autoZoom, createNarration, hide, video, voices } from 'screenci'
|
|
1138
|
-
|
|
1139
|
-
const narration = createNarration({
|
|
1140
|
-
voice: { name: voices.Sophie, style: 'Clear, friendly product walkthrough' },
|
|
1141
|
-
languages: {
|
|
1142
|
-
en: {
|
|
1143
|
-
cues: {
|
|
1144
|
-
docs: 'Use the guide sidebar to open the AI-Supported Editing guide and review the next steps for writing your own videos.',
|
|
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.',
|
|
1150
|
-
},
|
|
1151
|
-
},
|
|
1152
|
-
},
|
|
1153
|
-
})
|
|
1154
|
-
|
|
1155
|
-
video('See the next steps in ScreenCI docs', async ({ page }) => {
|
|
1156
|
-
await hide(async () => {
|
|
1157
|
-
await page.goto('https://screenci.com/')
|
|
1158
|
-
await page.getByText('ScreenCI', { exact: true }).first().waitFor()
|
|
1159
|
-
})
|
|
1160
|
-
|
|
1161
|
-
await autoZoom(
|
|
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
|
-
|
|
1172
|
-
await narration.docs()
|
|
1173
|
-
})
|
|
1174
|
-
`;
|
|
1175
|
-
}
|
|
1176
|
-
async function promptProjectName() {
|
|
1177
|
-
return input({ message: 'Project name:' });
|
|
1178
|
-
}
|
|
1179
|
-
async function promptInitDependencies() {
|
|
1180
|
-
return confirm({
|
|
1181
|
-
message: 'Install dependencies now, including Chromium for Playwright? (Y/n)',
|
|
1182
|
-
default: true,
|
|
1183
|
-
});
|
|
1184
|
-
}
|
|
1185
|
-
async function promptInitAiAuthoring() {
|
|
1186
|
-
return confirm({
|
|
1187
|
-
message: 'Do you want to write videos with an AI agent based on a URL and not just source code? If yes, playwright-cli will be also installed.',
|
|
1188
|
-
default: true,
|
|
1189
|
-
});
|
|
1190
|
-
}
|
|
1191
|
-
async function promptInitGithubActionCi() {
|
|
1192
|
-
return confirm({
|
|
1193
|
-
message: 'Do you want to add Github Action CI? (Y/n)',
|
|
1194
|
-
default: true,
|
|
1195
|
-
});
|
|
1196
|
-
}
|
|
1197
|
-
async function promptInitRepositoryMode() {
|
|
1198
|
-
return select({
|
|
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
|
-
});
|
|
1214
|
-
}
|
|
1215
|
-
function projectNameToDirectoryName(projectName) {
|
|
1216
|
-
return projectName.trim().replace(/\s+/g, '-');
|
|
1217
|
-
}
|
|
1218
|
-
function getInitProjectRoot() {
|
|
1219
|
-
return process.env['SCREENCI_INIT_CWD'] ?? process.cwd();
|
|
1220
|
-
}
|
|
1221
|
-
function getInitScreenciDependencyOverride() {
|
|
1222
|
-
return process.env['SCREENCI_INIT_SCREENCI_DEPENDENCY'];
|
|
1223
|
-
}
|
|
1224
|
-
export async function ensureScreenciSecret() {
|
|
1203
|
+
export async function ensureScreenciSecret(resolvedConfigPath) {
|
|
1225
1204
|
const existingSecret = process.env.SCREENCI_SECRET;
|
|
1226
1205
|
if (existingSecret)
|
|
1227
1206
|
return existingSecret;
|
|
@@ -1230,8 +1209,11 @@ export async function ensureScreenciSecret() {
|
|
|
1230
1209
|
try {
|
|
1231
1210
|
const secret = await performBrowserLogin(appUrl);
|
|
1232
1211
|
process.env.SCREENCI_SECRET = secret;
|
|
1233
|
-
const savePath =
|
|
1234
|
-
|
|
1212
|
+
const savePath = resolvedConfigPath
|
|
1213
|
+
? ((await resolveConfiguredEnvFilePath(resolvedConfigPath)) ??
|
|
1214
|
+
resolve(process.cwd(), '.env'))
|
|
1215
|
+
: resolve(process.cwd(), '.env');
|
|
1216
|
+
await appendFile(savePath, `SCREENCI_SECRET=${secret}\n`);
|
|
1235
1217
|
logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
|
|
1236
1218
|
return secret;
|
|
1237
1219
|
}
|
|
@@ -1242,166 +1224,14 @@ export async function ensureScreenciSecret() {
|
|
|
1242
1224
|
return undefined;
|
|
1243
1225
|
}
|
|
1244
1226
|
}
|
|
1245
|
-
async function runInit(projectNameArg, options) {
|
|
1246
|
-
const { verbose, install, yes, skill, ci, agent } = options;
|
|
1247
|
-
const initCwd = getInitProjectRoot();
|
|
1248
|
-
const existingRepositoryDetected = existsSync(resolve(initCwd, '.git'));
|
|
1249
|
-
let projectName = projectNameArg?.trim();
|
|
1250
|
-
if (!projectName) {
|
|
1251
|
-
projectName = await promptProjectName();
|
|
1252
|
-
}
|
|
1253
|
-
if (!projectName) {
|
|
1254
|
-
logger.error('Error: Project name is required');
|
|
1255
|
-
process.exit(1);
|
|
1256
|
-
}
|
|
1257
|
-
if (existingRepositoryDetected) {
|
|
1258
|
-
logger.info('Existing repository detected');
|
|
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');
|
|
1273
|
-
const githubActionPath = resolve(githubWorkflowsDir, 'screenci.yaml');
|
|
1274
|
-
const githubActionProjectDir = isPartOfExistingRepository ? 'screenci' : '.';
|
|
1275
|
-
if (existsSync(projectDir)) {
|
|
1276
|
-
logger.error(`Error: Directory "${dirName}" already exists`);
|
|
1277
|
-
process.exit(1);
|
|
1278
|
-
}
|
|
1279
|
-
const shouldInstallDependencies = yes
|
|
1280
|
-
? true
|
|
1281
|
-
: install
|
|
1282
|
-
? true
|
|
1283
|
-
: await promptInitDependencies();
|
|
1284
|
-
const shouldAddPlaywrightCli = yes
|
|
1285
|
-
? true
|
|
1286
|
-
: skill
|
|
1287
|
-
? true
|
|
1288
|
-
: await promptInitAiAuthoring();
|
|
1289
|
-
const shouldAddGithubActionCi = yes
|
|
1290
|
-
? true
|
|
1291
|
-
: ci
|
|
1292
|
-
? true
|
|
1293
|
-
: await promptInitGithubActionCi();
|
|
1294
|
-
if (shouldAddGithubActionCi && existsSync(githubActionPath)) {
|
|
1295
|
-
logger.error('Error: GitHub Actions workflow ".github/workflows/screenci.yaml" already exists');
|
|
1296
|
-
process.exit(1);
|
|
1297
|
-
}
|
|
1298
|
-
const skillsArgs = [
|
|
1299
|
-
'--yes',
|
|
1300
|
-
'skills',
|
|
1301
|
-
'add',
|
|
1302
|
-
'screenci/screenci',
|
|
1303
|
-
...(agent ? ['--agent', agent] : []),
|
|
1304
|
-
'--skill',
|
|
1305
|
-
'screenci',
|
|
1306
|
-
...(shouldAddPlaywrightCli ? ['--skill', 'playwright-cli'] : []),
|
|
1307
|
-
'-y',
|
|
1308
|
-
];
|
|
1309
|
-
const skillsCommand = `npx ${skillsArgs.join(' ')}`;
|
|
1310
|
-
const screenciDependency = getInitScreenciDependencyOverride() ?? (await readCurrentScreenciVersion());
|
|
1311
|
-
await mkdir(resolve(projectDir, 'videos'), { recursive: true });
|
|
1312
|
-
if (shouldAddGithubActionCi) {
|
|
1313
|
-
if (!existsSync(githubDir)) {
|
|
1314
|
-
await mkdir(githubDir);
|
|
1315
|
-
}
|
|
1316
|
-
if (!existsSync(githubWorkflowsDir)) {
|
|
1317
|
-
await mkdir(githubWorkflowsDir);
|
|
1318
|
-
}
|
|
1319
|
-
}
|
|
1320
|
-
await writeFile(resolve(projectDir, 'screenci.config.ts'), generateConfig(projectName));
|
|
1321
|
-
await writeFile(resolve(projectDir, 'package.json'), generatePackageJson(shouldAddPlaywrightCli, screenciDependency));
|
|
1322
|
-
await writeFile(resolve(projectDir, 'tsconfig.json'), generateTsconfig());
|
|
1323
|
-
await writeFile(resolve(projectDir, 'README.md'), generateReadme(projectName));
|
|
1324
|
-
await writeFile(resolve(projectDir, '.gitignore'), generateGitignore());
|
|
1325
|
-
await writeFile(resolve(projectDir, 'videos', 'example.video.ts'), generateExampleVideo());
|
|
1326
|
-
if (shouldAddGithubActionCi) {
|
|
1327
|
-
await writeFile(githubActionPath, generateGithubAction(githubActionProjectDir));
|
|
1328
|
-
}
|
|
1329
|
-
await writeFile(resolve(projectDir, '.env'), '');
|
|
1330
|
-
logger.info(`Initialized screenci project "${projectName}" in ${projectDir}/`);
|
|
1331
|
-
logger.info('Files created:');
|
|
1332
|
-
logger.info(' screenci.config.ts');
|
|
1333
|
-
logger.info(' package.json');
|
|
1334
|
-
logger.info(' tsconfig.json');
|
|
1335
|
-
logger.info(' README.md');
|
|
1336
|
-
logger.info(' .gitignore');
|
|
1337
|
-
logger.info(' videos/example.video.ts');
|
|
1338
|
-
if (shouldAddGithubActionCi) {
|
|
1339
|
-
const githubActionDisplayPath = isPartOfExistingRepository
|
|
1340
|
-
? '.github/workflows/screenci.yaml (outside ./screenci, at repository root)'
|
|
1341
|
-
: '.github/workflows/screenci.yaml';
|
|
1342
|
-
logger.info(` ${githubActionDisplayPath}`);
|
|
1343
|
-
}
|
|
1344
|
-
logger.info(' .env (empty placeholder)');
|
|
1345
|
-
logger.info('');
|
|
1346
|
-
if (shouldInstallDependencies) {
|
|
1347
|
-
if (verbose) {
|
|
1348
|
-
logger.info(`Running '${skillsCommand}'...`);
|
|
1349
|
-
await spawnInherited('npx', skillsArgs, projectDir, 'screenci init');
|
|
1350
|
-
}
|
|
1351
|
-
else {
|
|
1352
|
-
const spinner = ora('Adding ScreenCI skills...').start();
|
|
1353
|
-
try {
|
|
1354
|
-
await spawnSilent('npx', skillsArgs, projectDir);
|
|
1355
|
-
spinner.succeed('ScreenCI skills added');
|
|
1356
|
-
}
|
|
1357
|
-
catch (err) {
|
|
1358
|
-
spinner.fail('ScreenCI skills install failed');
|
|
1359
|
-
throw err;
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
if (verbose) {
|
|
1363
|
-
const installArgs = ['install', '--include=dev'];
|
|
1364
|
-
logger.info(`Running 'npm ${installArgs.join(' ')}'...`);
|
|
1365
|
-
await spawnInherited('npm', installArgs, projectDir, 'screenci init');
|
|
1366
|
-
}
|
|
1367
|
-
else {
|
|
1368
|
-
const spinner = ora('Running npm install...').start();
|
|
1369
|
-
try {
|
|
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
|
-
}
|
|
1378
|
-
}
|
|
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
|
-
}
|
|
1383
|
-
else {
|
|
1384
|
-
logger.info('Dependencies were not installed automatically.');
|
|
1385
|
-
logger.info('Run these commands when you are ready:');
|
|
1386
|
-
logger.info(` ${skillsCommand}`);
|
|
1387
|
-
logger.info(' npm install --include=dev');
|
|
1388
|
-
logger.info(' npx playwright install chromium --with-deps');
|
|
1389
|
-
}
|
|
1390
|
-
logger.info('');
|
|
1391
|
-
logger.info('Next steps:');
|
|
1392
|
-
logger.info(` cd ${dirName}`);
|
|
1393
|
-
logger.info(' Read README.md for setup and recording flow');
|
|
1394
|
-
logger.info(' Docs: https://screenci.com/docs/intro/');
|
|
1395
|
-
logger.info(' npx screenci test');
|
|
1396
|
-
logger.info(' npx screenci record');
|
|
1397
|
-
}
|
|
1398
1227
|
export async function main() {
|
|
1399
1228
|
if (process.argv.length <= 2) {
|
|
1400
1229
|
logger.error('Error: No command provided');
|
|
1401
|
-
logger.error('Available commands: record, test, info, make-public, make-private,
|
|
1230
|
+
logger.error('Available commands: record, test, info, make-public, make-private, init');
|
|
1402
1231
|
process.exit(1);
|
|
1403
1232
|
}
|
|
1404
1233
|
const program = new Command();
|
|
1234
|
+
const defaultPackageManager = determinePackageManager();
|
|
1405
1235
|
program.name('screenci');
|
|
1406
1236
|
program.exitOverride();
|
|
1407
1237
|
// record command — playwright args pass through as-is
|
|
@@ -1412,7 +1242,20 @@ export async function main() {
|
|
|
1412
1242
|
.allowUnknownOption(true)
|
|
1413
1243
|
.action(async () => {
|
|
1414
1244
|
const parsed = parseRecordCliArgs(getSubcommandArgv('record'));
|
|
1415
|
-
|
|
1245
|
+
let playwrightFailure = null;
|
|
1246
|
+
try {
|
|
1247
|
+
await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose);
|
|
1248
|
+
}
|
|
1249
|
+
catch (error) {
|
|
1250
|
+
logRecordFailureHint();
|
|
1251
|
+
if (error instanceof Error &&
|
|
1252
|
+
error.message.startsWith('Playwright exited with code ')) {
|
|
1253
|
+
playwrightFailure = error;
|
|
1254
|
+
}
|
|
1255
|
+
else {
|
|
1256
|
+
throw error;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1416
1259
|
if (process.env.SCREENCI_RECORDING === 'true')
|
|
1417
1260
|
return;
|
|
1418
1261
|
// After recording, upload results to API if configured
|
|
@@ -1427,47 +1270,86 @@ export async function main() {
|
|
|
1427
1270
|
const apiUrl = getDevBackendUrl();
|
|
1428
1271
|
const appUrl = getDevFrontendUrl();
|
|
1429
1272
|
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
|
-
}
|
|
1273
|
+
const uploadPolicy = resolveRecordUploadPolicy(screenciConfig);
|
|
1434
1274
|
const configDir = dirname(resolvedConfigPath);
|
|
1435
1275
|
const screenciDir = resolve(configDir, '.screenci');
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
logger.info('');
|
|
1439
|
-
projectId = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret);
|
|
1276
|
+
const completedRecordingCount = await countCompletedRecordings(screenciDir);
|
|
1277
|
+
if (playwrightFailure !== null && completedRecordingCount === 0) {
|
|
1278
|
+
logger.info('All recordings failed.');
|
|
1440
1279
|
}
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
process.exit(130);
|
|
1444
|
-
}
|
|
1445
|
-
throw err;
|
|
1280
|
+
else if (!secret) {
|
|
1281
|
+
logger.info('No secret configured, skipping upload. Set SCREENCI_SECRET in your .env file.');
|
|
1446
1282
|
}
|
|
1447
|
-
if (
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1283
|
+
else if (playwrightFailure !== null &&
|
|
1284
|
+
uploadPolicy === 'all-or-nothing') {
|
|
1285
|
+
logger.info('Some recordings failed, skipping upload because record.upload is "all-or-nothing".');
|
|
1286
|
+
}
|
|
1287
|
+
else {
|
|
1288
|
+
if (playwrightFailure !== null && uploadPolicy === 'passed-only') {
|
|
1289
|
+
logger.warn('Some recordings failed, uploading successful videos only.');
|
|
1290
|
+
}
|
|
1291
|
+
let uploadResult = {
|
|
1292
|
+
projectId: null,
|
|
1293
|
+
hadFailures: false,
|
|
1294
|
+
failedVideoNames: [],
|
|
1295
|
+
failedVideoMessages: [],
|
|
1296
|
+
};
|
|
1297
|
+
try {
|
|
1298
|
+
logger.info('');
|
|
1299
|
+
uploadResult = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret);
|
|
1300
|
+
}
|
|
1301
|
+
catch (err) {
|
|
1302
|
+
if (isUploadCancelledError(err)) {
|
|
1303
|
+
process.exit(130);
|
|
1304
|
+
}
|
|
1305
|
+
throw err;
|
|
1306
|
+
}
|
|
1307
|
+
const { projectId, hadFailures, failedVideoNames, failedVideoMessages, } = uploadResult;
|
|
1308
|
+
if (projectId !== null) {
|
|
1309
|
+
const projectUrl = `${appUrl}/project/${projectId}`;
|
|
1310
|
+
await writeGitHubProjectOutput(projectUrl);
|
|
1311
|
+
logger.info('');
|
|
1312
|
+
logger.info(playwrightFailure !== null
|
|
1313
|
+
? 'Recording partially succeeded, rendering in progress. Results available at:'
|
|
1314
|
+
: 'Recording finished, rendering in progress. Results available at:');
|
|
1315
|
+
logger.info(pc.cyan(projectUrl));
|
|
1316
|
+
}
|
|
1317
|
+
if (hadFailures) {
|
|
1318
|
+
for (const failedVideo of failedVideoMessages) {
|
|
1319
|
+
logger.warn(formatFailedVideoMessage(failedVideo.videoName, failedVideo.message));
|
|
1320
|
+
}
|
|
1321
|
+
logger.warn(`Not all recordings succeeded to upload. Failed videos: ${failedVideoNames.join(', ') || 'unknown'}. Some videos may be missing from the project.`);
|
|
1322
|
+
if (playwrightFailure === null) {
|
|
1323
|
+
throw new PartialUploadError();
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1453
1326
|
}
|
|
1454
1327
|
}
|
|
1455
1328
|
catch (err) {
|
|
1329
|
+
if (isPartialUploadError(err)) {
|
|
1330
|
+
throw err;
|
|
1331
|
+
}
|
|
1456
1332
|
logger.warn('Failed to load config for upload:', err);
|
|
1457
1333
|
}
|
|
1458
1334
|
}
|
|
1335
|
+
if (playwrightFailure !== null) {
|
|
1336
|
+
throw playwrightFailure;
|
|
1337
|
+
}
|
|
1459
1338
|
});
|
|
1460
1339
|
program
|
|
1461
1340
|
.command('test [playwrightArgs...]')
|
|
1462
1341
|
.description('Run Playwright test with screenci.config.ts')
|
|
1342
|
+
.option('--mock-record', 'keep recording-style cursor animation and sleeps during screenci test')
|
|
1463
1343
|
.option('-v, --verbose', 'verbose output')
|
|
1464
1344
|
.allowUnknownOption(true)
|
|
1465
1345
|
.action(async () => {
|
|
1466
1346
|
const parsed = parseConfigCliArgs(getSubcommandArgv('test'));
|
|
1347
|
+
let configMockRecord = false;
|
|
1467
1348
|
const resolvedConfigPath = findScreenCIConfig(parsed.configPath);
|
|
1468
1349
|
if (resolvedConfigPath) {
|
|
1469
1350
|
try {
|
|
1470
1351
|
const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
|
|
1352
|
+
configMockRecord = screenciConfig.test?.mockRecord ?? false;
|
|
1471
1353
|
if (screenciConfig.envFile) {
|
|
1472
1354
|
const envFilePath = resolve(dirname(resolvedConfigPath), screenciConfig.envFile);
|
|
1473
1355
|
loadEnvFile(envFilePath, true);
|
|
@@ -1477,7 +1359,7 @@ export async function main() {
|
|
|
1477
1359
|
logger.warn('Failed to load config for test env:', err);
|
|
1478
1360
|
}
|
|
1479
1361
|
}
|
|
1480
|
-
await run('test', parsed.otherArgs, parsed.configPath, parsed.verbose);
|
|
1362
|
+
await run('test', parsed.otherArgs, parsed.configPath, parsed.verbose, parsed.mockRecord || configMockRecord);
|
|
1481
1363
|
if (process.env.SCREENCI_RECORDING === 'true')
|
|
1482
1364
|
return;
|
|
1483
1365
|
logger.info('');
|
|
@@ -1504,33 +1386,20 @@ export async function main() {
|
|
|
1504
1386
|
.action(async (id, options) => {
|
|
1505
1387
|
await updateVideoVisibility(id, false, options['config']);
|
|
1506
1388
|
});
|
|
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
1389
|
// init command
|
|
1517
1390
|
program
|
|
1518
1391
|
.command('init [name]')
|
|
1519
1392
|
.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
1393
|
.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', '
|
|
1394
|
+
.option('--package-manager <manager>', `package manager to use: npm or pnpm (default: ${defaultPackageManager})`)
|
|
1395
|
+
.option('-y, --yes', 'accept init defaults')
|
|
1525
1396
|
.option('-v, --verbose', 'verbose output')
|
|
1526
1397
|
.action(async (name, options) => {
|
|
1527
1398
|
const agent = options['agent'];
|
|
1528
1399
|
await runInit(name, {
|
|
1529
1400
|
verbose: options['verbose'] ?? false,
|
|
1530
|
-
install: options['install'] ?? false,
|
|
1531
1401
|
yes: options['yes'] ?? false,
|
|
1532
|
-
|
|
1533
|
-
ci: options['ci'] ?? false,
|
|
1402
|
+
packageManager: parsePackageManager(options['packageManager']),
|
|
1534
1403
|
...(agent !== undefined ? { agent } : {}),
|
|
1535
1404
|
});
|
|
1536
1405
|
});
|
|
@@ -1603,6 +1472,7 @@ function parseRecordCliArgs(args) {
|
|
|
1603
1472
|
function parseConfigCliArgs(args) {
|
|
1604
1473
|
let configPath;
|
|
1605
1474
|
let verbose = false;
|
|
1475
|
+
let mockRecord = false;
|
|
1606
1476
|
const otherArgs = [];
|
|
1607
1477
|
for (let i = 0; i < args.length; i++) {
|
|
1608
1478
|
const arg = args[i];
|
|
@@ -1620,65 +1490,33 @@ function parseConfigCliArgs(args) {
|
|
|
1620
1490
|
else if (arg === '--verbose' || arg === '-v') {
|
|
1621
1491
|
verbose = true;
|
|
1622
1492
|
}
|
|
1493
|
+
else if (arg === '--mock-record') {
|
|
1494
|
+
mockRecord = true;
|
|
1495
|
+
}
|
|
1623
1496
|
else {
|
|
1624
1497
|
otherArgs.push(arg);
|
|
1625
1498
|
}
|
|
1626
1499
|
}
|
|
1627
|
-
return { configPath, verbose, otherArgs };
|
|
1500
|
+
return { configPath, verbose, mockRecord, otherArgs };
|
|
1628
1501
|
}
|
|
1629
1502
|
function validateArgs(args) {
|
|
1630
|
-
const disallowedFlags = ['--
|
|
1503
|
+
const disallowedFlags = ['--retries'];
|
|
1631
1504
|
for (const arg of args) {
|
|
1632
1505
|
if (arg === undefined)
|
|
1633
1506
|
continue;
|
|
1634
1507
|
// Check if it's a disallowed flag
|
|
1635
1508
|
if (disallowedFlags.includes(arg)) {
|
|
1636
1509
|
throw new Error(`Flag "${arg}" is not supported by screenci. ` +
|
|
1637
|
-
'screenci
|
|
1510
|
+
'screenci forces retries to 0 for proper video recording.');
|
|
1638
1511
|
}
|
|
1639
|
-
// Check if it's a --
|
|
1640
|
-
if (arg.startsWith('--
|
|
1641
|
-
arg.startsWith('-j=') ||
|
|
1642
|
-
arg.startsWith('--retries=')) {
|
|
1512
|
+
// Check if it's a --retries=N format
|
|
1513
|
+
if (arg.startsWith('--retries=')) {
|
|
1643
1514
|
throw new Error(`Flag "${arg}" is not supported by screenci. ` +
|
|
1644
|
-
'screenci
|
|
1515
|
+
'screenci forces retries to 0 for proper video recording.');
|
|
1645
1516
|
}
|
|
1646
1517
|
}
|
|
1647
1518
|
}
|
|
1648
|
-
function
|
|
1649
|
-
const spawnSpec = resolveSpawnSpec(cmd, args);
|
|
1650
|
-
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
1651
|
-
stdio: 'inherit',
|
|
1652
|
-
...(spawnSpec.shell !== undefined ? { shell: spawnSpec.shell } : {}),
|
|
1653
|
-
...(cwd ? { cwd } : {}),
|
|
1654
|
-
});
|
|
1655
|
-
const childSignals = forwardChildSignals(child, activityLabel);
|
|
1656
|
-
return new Promise((resolve, reject) => {
|
|
1657
|
-
child.on('close', (code, signal) => {
|
|
1658
|
-
const forwardedSignal = childSignals.getForwardedSignal();
|
|
1659
|
-
childSignals.cleanup();
|
|
1660
|
-
if (forwardedSignal) {
|
|
1661
|
-
process.kill(process.pid, forwardedSignal);
|
|
1662
|
-
return;
|
|
1663
|
-
}
|
|
1664
|
-
if (signal) {
|
|
1665
|
-
process.kill(process.pid, signal);
|
|
1666
|
-
return;
|
|
1667
|
-
}
|
|
1668
|
-
else if (code === 0) {
|
|
1669
|
-
resolve();
|
|
1670
|
-
}
|
|
1671
|
-
else {
|
|
1672
|
-
reject(new Error(`${cmd} exited with code ${code}`));
|
|
1673
|
-
}
|
|
1674
|
-
});
|
|
1675
|
-
child.on('error', (err) => {
|
|
1676
|
-
childSignals.cleanup();
|
|
1677
|
-
reject(err);
|
|
1678
|
-
});
|
|
1679
|
-
});
|
|
1680
|
-
}
|
|
1681
|
-
async function run(command, additionalArgs, customConfigPath, verbose = false) {
|
|
1519
|
+
async function run(command, additionalArgs, customConfigPath, verbose = false, mockRecord = false) {
|
|
1682
1520
|
const configPath = findScreenCIConfig(customConfigPath);
|
|
1683
1521
|
if (!configPath) {
|
|
1684
1522
|
const errorMsg = customConfigPath
|
|
@@ -1690,14 +1528,24 @@ async function run(command, additionalArgs, customConfigPath, verbose = false) {
|
|
|
1690
1528
|
if (command === 'test' || process.env.SCREENCI_RECORDING !== 'true') {
|
|
1691
1529
|
await loadEnvFileFromConfigSource(configPath, false);
|
|
1692
1530
|
}
|
|
1693
|
-
const envForChild = { ...process.env };
|
|
1694
1531
|
// Only validate args for record command
|
|
1695
1532
|
if (command === 'record') {
|
|
1696
|
-
await ensureScreenciSecret();
|
|
1533
|
+
await ensureScreenciSecret(configPath);
|
|
1697
1534
|
validateArgs(additionalArgs);
|
|
1698
1535
|
const screenciDir = resolve(dirname(configPath), '.screenci');
|
|
1699
1536
|
clearDirectory(screenciDir);
|
|
1700
1537
|
}
|
|
1538
|
+
const envForChild = { ...process.env };
|
|
1539
|
+
await validateUniqueDiscoveredTestTitles(configPath, additionalArgs, {
|
|
1540
|
+
...envForChild,
|
|
1541
|
+
...(command === 'record' ? { SCREENCI_RECORDING: 'true' } : {}),
|
|
1542
|
+
...(command === 'test' && !mockRecord
|
|
1543
|
+
? { [SCREENCI_DISABLE_RECORDING_TIMINGS_ENV]: 'true' }
|
|
1544
|
+
: {}),
|
|
1545
|
+
...(command === 'test' && mockRecord
|
|
1546
|
+
? { [SCREENCI_MOCK_RECORD_ENV]: 'true' }
|
|
1547
|
+
: {}),
|
|
1548
|
+
});
|
|
1701
1549
|
if (verbose && process.env.SCREENCI_RECORDING !== 'true') {
|
|
1702
1550
|
logger.info(`Using config: ${configPath}`);
|
|
1703
1551
|
}
|
|
@@ -1705,32 +1553,49 @@ async function run(command, additionalArgs, customConfigPath, verbose = false) {
|
|
|
1705
1553
|
const spawnSpec = resolveSpawnSpec('playwright', playwrightArgs);
|
|
1706
1554
|
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
1707
1555
|
stdio: 'inherit',
|
|
1556
|
+
...(process.platform !== 'win32' ? { detached: true } : {}),
|
|
1708
1557
|
...(spawnSpec.shell !== undefined ? { shell: spawnSpec.shell } : {}),
|
|
1558
|
+
...(spawnSpec.windowsVerbatimArguments !== undefined
|
|
1559
|
+
? {
|
|
1560
|
+
windowsVerbatimArguments: spawnSpec.windowsVerbatimArguments,
|
|
1561
|
+
}
|
|
1562
|
+
: {}),
|
|
1709
1563
|
env: {
|
|
1710
1564
|
...envForChild,
|
|
1711
1565
|
// Enable recording only for record command
|
|
1712
1566
|
...(command === 'record' ? { SCREENCI_RECORDING: 'true' } : {}),
|
|
1567
|
+
...(command === 'test' && !mockRecord
|
|
1568
|
+
? { [SCREENCI_DISABLE_RECORDING_TIMINGS_ENV]: 'true' }
|
|
1569
|
+
: {}),
|
|
1570
|
+
...(command === 'test' && mockRecord
|
|
1571
|
+
? { [SCREENCI_MOCK_RECORD_ENV]: 'true' }
|
|
1572
|
+
: {}),
|
|
1713
1573
|
},
|
|
1714
1574
|
});
|
|
1715
|
-
const childSignals = forwardChildSignals(child, `screenci ${command}
|
|
1575
|
+
const childSignals = forwardChildSignals(child, `screenci ${command}`, {
|
|
1576
|
+
killTree: process.platform !== 'win32',
|
|
1577
|
+
exitParentOnForward: true,
|
|
1578
|
+
});
|
|
1716
1579
|
return new Promise((resolve, reject) => {
|
|
1717
1580
|
child.on('close', (code, signal) => {
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1581
|
+
void (async () => {
|
|
1582
|
+
const forwardedSignal = childSignals.getForwardedSignal();
|
|
1583
|
+
childSignals.cleanup();
|
|
1584
|
+
if (forwardedSignal) {
|
|
1585
|
+
process.kill(process.pid, forwardedSignal);
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
if (signal) {
|
|
1589
|
+
process.kill(process.pid, signal);
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
if (code === 0) {
|
|
1593
|
+
resolve();
|
|
1594
|
+
}
|
|
1595
|
+
else {
|
|
1596
|
+
reject(new Error(`Playwright exited with code ${code}`));
|
|
1597
|
+
}
|
|
1598
|
+
})().catch(reject);
|
|
1734
1599
|
});
|
|
1735
1600
|
child.on('error', (err) => {
|
|
1736
1601
|
childSignals.cleanup();
|
|
@@ -1738,6 +1603,11 @@ async function run(command, additionalArgs, customConfigPath, verbose = false) {
|
|
|
1738
1603
|
});
|
|
1739
1604
|
});
|
|
1740
1605
|
}
|
|
1606
|
+
function logRecordFailureHint() {
|
|
1607
|
+
logger.info('');
|
|
1608
|
+
logger.info(`If ${pc.cyan('screenci test')} works but ${pc.cyan('screenci record')} fails, try ${pc.cyan('screenci test --mock-record')}.`);
|
|
1609
|
+
logger.info(`More info: ${pc.cyan(SCREENCI_MOCK_RECORD_DOCS_URL)}`);
|
|
1610
|
+
}
|
|
1741
1611
|
// Only run if this file is being executed directly
|
|
1742
1612
|
// Check if this module is the main module (handles symlinks properly)
|
|
1743
1613
|
const currentFile = fileURLToPath(import.meta.url);
|
|
@@ -1748,6 +1618,9 @@ if (mainFile &&
|
|
|
1748
1618
|
currentRealFile === mainFile ||
|
|
1749
1619
|
currentFile === realpathSync(mainFile))) {
|
|
1750
1620
|
main().catch((error) => {
|
|
1621
|
+
if (isPartialUploadError(error)) {
|
|
1622
|
+
process.exit(1);
|
|
1623
|
+
}
|
|
1751
1624
|
logger.error('Error:', error.message);
|
|
1752
1625
|
process.exit(1);
|
|
1753
1626
|
});
|