screenci 0.0.43 → 0.0.45

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