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.
Files changed (170) hide show
  1. package/LICENCE +21 -0
  2. package/README.md +77 -195
  3. package/dist/cli.d.ts +22 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +640 -767
  6. package/dist/cli.js.map +1 -1
  7. package/dist/docs/manifest.d.ts +529 -0
  8. package/dist/docs/manifest.d.ts.map +1 -0
  9. package/dist/docs/manifest.js +243 -0
  10. package/dist/docs/manifest.js.map +1 -0
  11. package/dist/docs/video-sources/assets-and-overlays.video.d.ts +2 -0
  12. package/dist/docs/video-sources/assets-and-overlays.video.d.ts.map +1 -0
  13. package/dist/docs/video-sources/assets-and-overlays.video.js +40 -0
  14. package/dist/docs/video-sources/assets-and-overlays.video.js.map +1 -0
  15. package/dist/docs/video-sources/camera-and-zooming.video.d.ts +2 -0
  16. package/dist/docs/video-sources/camera-and-zooming.video.d.ts.map +1 -0
  17. package/dist/docs/video-sources/camera-and-zooming.video.js +37 -0
  18. package/dist/docs/video-sources/camera-and-zooming.video.js.map +1 -0
  19. package/dist/docs/video-sources/ci-setup.video.d.ts +2 -0
  20. package/dist/docs/video-sources/ci-setup.video.d.ts.map +1 -0
  21. package/dist/docs/video-sources/ci-setup.video.js +37 -0
  22. package/dist/docs/video-sources/ci-setup.video.js.map +1 -0
  23. package/dist/docs/video-sources/cli.video.d.ts +2 -0
  24. package/dist/docs/video-sources/cli.video.d.ts.map +1 -0
  25. package/dist/docs/video-sources/cli.video.js +37 -0
  26. package/dist/docs/video-sources/cli.video.js.map +1 -0
  27. package/dist/docs/video-sources/docs-shared.d.ts +5 -0
  28. package/dist/docs/video-sources/docs-shared.d.ts.map +1 -0
  29. package/dist/docs/video-sources/docs-shared.js +14 -0
  30. package/dist/docs/video-sources/docs-shared.js.map +1 -0
  31. package/dist/docs/video-sources/generating-videos.video.d.ts +2 -0
  32. package/dist/docs/video-sources/generating-videos.video.d.ts.map +1 -0
  33. package/dist/docs/video-sources/generating-videos.video.js +37 -0
  34. package/dist/docs/video-sources/generating-videos.video.js.map +1 -0
  35. package/dist/docs/video-sources/installation.video.d.ts +2 -0
  36. package/dist/docs/video-sources/installation.video.d.ts.map +1 -0
  37. package/dist/docs/video-sources/installation.video.js +24 -0
  38. package/dist/docs/video-sources/installation.video.js.map +1 -0
  39. package/dist/docs/video-sources/narration-and-localization.video.d.ts +2 -0
  40. package/dist/docs/video-sources/narration-and-localization.video.d.ts.map +1 -0
  41. package/dist/docs/video-sources/narration-and-localization.video.js +40 -0
  42. package/dist/docs/video-sources/narration-and-localization.video.js.map +1 -0
  43. package/dist/docs/video-sources/public-urls-and-embeds.video.d.ts +2 -0
  44. package/dist/docs/video-sources/public-urls-and-embeds.video.d.ts.map +1 -0
  45. package/dist/docs/video-sources/public-urls-and-embeds.video.js +37 -0
  46. package/dist/docs/video-sources/public-urls-and-embeds.video.js.map +1 -0
  47. package/dist/docs/video-sources/record-and-publish.video.d.ts +2 -0
  48. package/dist/docs/video-sources/record-and-publish.video.d.ts.map +1 -0
  49. package/dist/docs/video-sources/record-and-publish.video.js +37 -0
  50. package/dist/docs/video-sources/record-and-publish.video.js.map +1 -0
  51. package/dist/docs/video-sources/run-and-debug-videos.video.d.ts +2 -0
  52. package/dist/docs/video-sources/run-and-debug-videos.video.d.ts.map +1 -0
  53. package/dist/docs/video-sources/run-and-debug-videos.video.js +37 -0
  54. package/dist/docs/video-sources/run-and-debug-videos.video.js.map +1 -0
  55. package/dist/docs/video-sources/write-video-scripts.video.d.ts +2 -0
  56. package/dist/docs/video-sources/write-video-scripts.video.d.ts.map +1 -0
  57. package/dist/docs/video-sources/write-video-scripts.video.js +40 -0
  58. package/dist/docs/video-sources/write-video-scripts.video.js.map +1 -0
  59. package/dist/docs/videos.d.ts +94 -0
  60. package/dist/docs/videos.d.ts.map +1 -0
  61. package/dist/docs/videos.js +54 -0
  62. package/dist/docs/videos.js.map +1 -0
  63. package/dist/index.d.ts +4 -10
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +1 -6
  66. package/dist/index.js.map +1 -1
  67. package/dist/src/asset.d.ts +10 -9
  68. package/dist/src/asset.d.ts.map +1 -1
  69. package/dist/src/asset.js +7 -13
  70. package/dist/src/asset.js.map +1 -1
  71. package/dist/src/autoZoom.d.ts +3 -33
  72. package/dist/src/autoZoom.d.ts.map +1 -1
  73. package/dist/src/autoZoom.js +46 -51
  74. package/dist/src/autoZoom.js.map +1 -1
  75. package/dist/src/changeFocus.d.ts.map +1 -1
  76. package/dist/src/changeFocus.js +3 -2
  77. package/dist/src/changeFocus.js.map +1 -1
  78. package/dist/src/config.d.ts +5 -7
  79. package/dist/src/config.d.ts.map +1 -1
  80. package/dist/src/config.js +44 -71
  81. package/dist/src/config.js.map +1 -1
  82. package/dist/src/cue.d.ts +13 -26
  83. package/dist/src/cue.d.ts.map +1 -1
  84. package/dist/src/cue.js +61 -97
  85. package/dist/src/cue.js.map +1 -1
  86. package/dist/src/customVoiceRef.d.ts +3 -0
  87. package/dist/src/customVoiceRef.d.ts.map +1 -0
  88. package/dist/src/customVoiceRef.js +7 -0
  89. package/dist/src/customVoiceRef.js.map +1 -0
  90. package/dist/src/defaults.d.ts +5 -5
  91. package/dist/src/defaults.d.ts.map +1 -1
  92. package/dist/src/defaults.js +13 -7
  93. package/dist/src/defaults.js.map +1 -1
  94. package/dist/src/events.d.ts +7 -6
  95. package/dist/src/events.d.ts.map +1 -1
  96. package/dist/src/events.js +17 -0
  97. package/dist/src/events.js.map +1 -1
  98. package/dist/src/fileSystemName.d.ts +2 -0
  99. package/dist/src/fileSystemName.d.ts.map +1 -0
  100. package/dist/src/fileSystemName.js +44 -0
  101. package/dist/src/fileSystemName.js.map +1 -0
  102. package/dist/src/hide.d.ts +1 -1
  103. package/dist/src/hide.d.ts.map +1 -1
  104. package/dist/src/hide.js +12 -15
  105. package/dist/src/hide.js.map +1 -1
  106. package/dist/src/init.d.ts +12 -0
  107. package/dist/src/init.d.ts.map +1 -0
  108. package/dist/src/init.js +674 -0
  109. package/dist/src/init.js.map +1 -0
  110. package/dist/src/instrument.d.ts +1 -0
  111. package/dist/src/instrument.d.ts.map +1 -1
  112. package/dist/src/instrument.js +29 -10
  113. package/dist/src/instrument.js.map +1 -1
  114. package/dist/src/manualZoom.d.ts.map +1 -1
  115. package/dist/src/manualZoom.js +13 -13
  116. package/dist/src/manualZoom.js.map +1 -1
  117. package/dist/src/mouse.d.ts.map +1 -1
  118. package/dist/src/mouse.js +4 -1
  119. package/dist/src/mouse.js.map +1 -1
  120. package/dist/src/recording.d.ts +2 -2
  121. package/dist/src/recording.d.ts.map +1 -1
  122. package/dist/src/runtimeContext.d.ts +81 -0
  123. package/dist/src/runtimeContext.d.ts.map +1 -0
  124. package/dist/src/runtimeContext.js +95 -0
  125. package/dist/src/runtimeContext.js.map +1 -0
  126. package/dist/src/runtimeMode.d.ts +8 -0
  127. package/dist/src/runtimeMode.d.ts.map +1 -0
  128. package/dist/src/runtimeMode.js +22 -0
  129. package/dist/src/runtimeMode.js.map +1 -0
  130. package/dist/src/titleValidation.d.ts +3 -0
  131. package/dist/src/titleValidation.d.ts.map +1 -0
  132. package/dist/src/titleValidation.js +19 -0
  133. package/dist/src/titleValidation.js.map +1 -0
  134. package/dist/src/types.d.ts +42 -15
  135. package/dist/src/types.d.ts.map +1 -1
  136. package/dist/src/types.js.map +1 -1
  137. package/dist/src/video.d.ts +6 -1
  138. package/dist/src/video.d.ts.map +1 -1
  139. package/dist/src/video.js +94 -46
  140. package/dist/src/video.js.map +1 -1
  141. package/dist/src/voices.d.ts +8 -11
  142. package/dist/src/voices.d.ts.map +1 -1
  143. package/dist/src/voices.js +13 -8
  144. package/dist/src/voices.js.map +1 -1
  145. package/dist/src/zoom.d.ts +1 -1
  146. package/dist/src/zoom.d.ts.map +1 -1
  147. package/dist/test-fixtures/record-all-or-nothing.config.d.ts +8 -0
  148. package/dist/test-fixtures/record-all-or-nothing.config.d.ts.map +1 -0
  149. package/dist/test-fixtures/record-all-or-nothing.config.js +7 -0
  150. package/dist/test-fixtures/record-all-or-nothing.config.js.map +1 -0
  151. package/dist/test-fixtures/record-upload-all-or-nothing.config.d.ts +8 -0
  152. package/dist/test-fixtures/record-upload-all-or-nothing.config.d.ts.map +1 -0
  153. package/dist/test-fixtures/record-upload-all-or-nothing.config.js +7 -0
  154. package/dist/test-fixtures/record-upload-all-or-nothing.config.js.map +1 -0
  155. package/dist/test-fixtures/record-upload.config.d.ts +5 -0
  156. package/dist/test-fixtures/record-upload.config.d.ts.map +1 -0
  157. package/dist/test-fixtures/record-upload.config.js +4 -0
  158. package/dist/test-fixtures/record-upload.config.js.map +1 -0
  159. package/dist/tsconfig.tsbuildinfo +1 -1
  160. package/package.json +8 -2
  161. package/skills/screenci/SKILL.md +6 -1
  162. package/skills/screenci/references/init.md +10 -12
  163. package/dist/src/reporter.d.ts +0 -9
  164. package/dist/src/reporter.d.ts.map +0 -1
  165. package/dist/src/reporter.js +0 -50
  166. package/dist/src/reporter.js.map +0 -1
  167. package/dist/src/sanitize.d.ts +0 -5
  168. package/dist/src/sanitize.d.ts.map +0 -1
  169. package/dist/src/sanitize.js +0 -11
  170. 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, mkdir, readdir, readFile, stat, writeFile, } from 'fs/promises';
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 windowsCmdsNeedingShell = new Set(['npm', 'npx', 'playwright']);
121
- if (!windowsCmdsNeedingShell.has(cmd)) {
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', commandLine],
417
+ command: process.env.comspec ?? 'cmd.exe',
418
+ args: ['/d', '/s', '/c', `"${buildWindowsBatchCommandLine(cmd, args)}"`],
419
+ windowsVerbatimArguments: true,
128
420
  };
129
421
  }
130
- function spawnSilent(cmd, args, cwd) {
131
- return new Promise((resolve, reject) => {
132
- const spawnSpec = resolveSpawnSpec(cmd, args);
133
- const child = spawn(spawnSpec.command, spawnSpec.args, {
134
- stdio: 'pipe',
135
- ...(spawnSpec.shell !== undefined ? { shell: spawnSpec.shell } : {}),
136
- ...(cwd ? { cwd } : {}),
137
- });
138
- const childSignals = forwardChildSignals(child, cmd);
139
- child.on('close', (code, signal) => {
140
- const forwardedSignal = childSignals.getForwardedSignal();
141
- childSignals.cleanup();
142
- if (forwardedSignal) {
143
- process.kill(process.pid, forwardedSignal);
144
- return;
145
- }
146
- if (signal) {
147
- process.kill(process.pid, signal);
148
- return;
149
- }
150
- else if (code === 0) {
151
- resolve();
152
- }
153
- else {
154
- reject(new Error(`${cmd} exited with code ${code}`));
155
- }
156
- });
157
- child.on('error', (err) => {
158
- childSignals.cleanup();
159
- reject(err);
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
- if (!child.killed) {
174
- child.kill(signal);
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
- child.kill('SIGKILL');
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
- logger.info(`Asset already exists: ${asset.path}`);
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
- logger.info(`Asset already exists: ${asset.path}`);
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
- logger.info(`Asset uploaded: ${asset.path}`);
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 null;
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
- for (const entry of entries) {
896
+ const candidates = (await Promise.all(entries.map(async (entry) => {
592
897
  uploadAbort.throwIfAborted();
593
- const dataJsonPath = resolve(screenciDir, entry, 'data.json');
594
- if (!existsSync(dataJsonPath)) {
595
- if (verbose)
596
- logger.info(`Skipping "${entry}": no data.json found`);
597
- continue;
598
- }
599
- let data;
600
- try {
601
- const raw = await readFile(dataJsonPath, 'utf-8');
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
- return firstProjectId;
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
- if (err instanceof Error &&
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 generateExampleVideo() {
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 = resolve(process.cwd(), '.env');
1234
- await writeFile(savePath, `SCREENCI_SECRET=${secret}\n`);
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, retry, init');
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
- await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose);
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
- if (!secret) {
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
- let projectId = null;
1437
- try {
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
- catch (err) {
1442
- if (isUploadCancelledError(err)) {
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 (projectId !== null) {
1448
- const projectUrl = `${appUrl}/project/${projectId}`;
1449
- await writeGitHubProjectOutput(projectUrl);
1450
- logger.info('');
1451
- logger.info('Recording finished, rendering in progress. Results available at:');
1452
- logger.info(pc.cyan(projectUrl));
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('--skill', 'enable playwright-cli without prompting')
1524
- .option('-y, --yes', 'answer yes to all init prompts')
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
- skill: options['skill'] ?? false,
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 = ['--fully-parallel', '--workers', '-j', '--retries'];
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 enforces sequential test execution with a single worker and no retries for proper video recording.');
1510
+ 'screenci forces retries to 0 for proper video recording.');
1638
1511
  }
1639
- // Check if it's a --workers=N, -j=N, or --retries=N format
1640
- if (arg.startsWith('--workers=') ||
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 enforces sequential test execution with a single worker and no retries for proper video recording.');
1515
+ 'screenci forces retries to 0 for proper video recording.');
1645
1516
  }
1646
1517
  }
1647
1518
  }
1648
- function spawnInherited(cmd, args, cwd, activityLabel = cmd) {
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
- const forwardedSignal = childSignals.getForwardedSignal();
1719
- childSignals.cleanup();
1720
- if (forwardedSignal) {
1721
- process.kill(process.pid, forwardedSignal);
1722
- return;
1723
- }
1724
- if (signal) {
1725
- process.kill(process.pid, signal);
1726
- return;
1727
- }
1728
- if (code === 0) {
1729
- resolve();
1730
- }
1731
- else {
1732
- reject(new Error(`Playwright exited with code ${code}`));
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
  });