screenci 0.0.62 → 0.0.63

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 (67) hide show
  1. package/README.md +15 -4
  2. package/bin/screenci.js +2 -2
  3. package/dist/cli.d.ts +29 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +310 -207
  6. package/dist/cli.js.map +1 -1
  7. package/dist/docs/manifest.d.ts +51 -20
  8. package/dist/docs/manifest.d.ts.map +1 -1
  9. package/dist/docs/manifest.js +17 -6
  10. package/dist/docs/manifest.js.map +1 -1
  11. package/dist/e2e/instrument.e2e.js +11 -11
  12. package/dist/e2e/instrument.e2e.js.map +1 -1
  13. package/dist/index.d.ts +6 -4
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/src/asset.d.ts +27 -0
  18. package/dist/src/asset.d.ts.map +1 -1
  19. package/dist/src/asset.js +46 -0
  20. package/dist/src/asset.js.map +1 -1
  21. package/dist/src/changeFocus.d.ts.map +1 -1
  22. package/dist/src/changeFocus.js +3 -3
  23. package/dist/src/changeFocus.js.map +1 -1
  24. package/dist/src/cue.d.ts +60 -13
  25. package/dist/src/cue.d.ts.map +1 -1
  26. package/dist/src/cue.js +153 -47
  27. package/dist/src/cue.js.map +1 -1
  28. package/dist/src/events.d.ts +56 -8
  29. package/dist/src/events.d.ts.map +1 -1
  30. package/dist/src/events.js +47 -1
  31. package/dist/src/events.js.map +1 -1
  32. package/dist/src/git.d.ts +15 -0
  33. package/dist/src/git.d.ts.map +1 -0
  34. package/dist/src/git.js +43 -0
  35. package/dist/src/git.js.map +1 -0
  36. package/dist/src/init.d.ts.map +1 -1
  37. package/dist/src/init.js +1 -4
  38. package/dist/src/init.js.map +1 -1
  39. package/dist/src/instrument.d.ts.map +1 -1
  40. package/dist/src/instrument.js +49 -125
  41. package/dist/src/instrument.js.map +1 -1
  42. package/dist/src/mouse.d.ts +1 -0
  43. package/dist/src/mouse.d.ts.map +1 -1
  44. package/dist/src/mouse.js +9 -3
  45. package/dist/src/mouse.js.map +1 -1
  46. package/dist/src/recording.d.ts +1 -1
  47. package/dist/src/recording.d.ts.map +1 -1
  48. package/dist/src/recordingData.d.ts +43 -1
  49. package/dist/src/recordingData.d.ts.map +1 -1
  50. package/dist/src/studio.d.ts +36 -0
  51. package/dist/src/studio.d.ts.map +1 -0
  52. package/dist/src/studio.js +39 -0
  53. package/dist/src/studio.js.map +1 -0
  54. package/dist/src/types.d.ts +141 -125
  55. package/dist/src/types.d.ts.map +1 -1
  56. package/dist/src/types.js +1 -0
  57. package/dist/src/types.js.map +1 -1
  58. package/dist/src/video.d.ts +2 -1
  59. package/dist/src/video.d.ts.map +1 -1
  60. package/dist/src/video.js.map +1 -1
  61. package/dist/src/voices.d.ts +3 -3
  62. package/dist/src/voices.d.ts.map +1 -1
  63. package/dist/tsconfig.tsbuildinfo +1 -1
  64. package/package.json +1 -1
  65. package/skills/screenci/SKILL.md +4 -7
  66. package/skills/screenci/references/init.md +1 -2
  67. package/skills/screenci/references/record.md +2 -9
package/dist/cli.js CHANGED
@@ -1,12 +1,11 @@
1
1
  import { spawn } from 'child_process';
2
2
  import { createReadStream } from 'fs';
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, } from 'fs';
4
- import { createHash } from 'crypto';
5
- import { createServer } from 'http';
4
+ import { createHash, randomUUID } from 'crypto';
5
+ import { createRequire } from 'module';
6
6
  import { appendFile, readdir, readFile, stat, writeFile } from 'fs/promises';
7
7
  import { delimiter, dirname, relative as pathRelative, resolve } from 'path';
8
8
  import { fileURLToPath, pathToFileURL } from 'url';
9
- import { confirm } from '@inquirer/prompts';
10
9
  import { Command, CommanderError } from 'commander';
11
10
  import pc from 'picocolors';
12
11
  import { logger } from './src/logger.js';
@@ -15,8 +14,28 @@ import { SCREENCI_DISABLE_RECORDING_TIMINGS_ENV, SCREENCI_MOCK_RECORD_ENV, } fro
15
14
  import { DEFAULT_RECORD_UPLOAD_POLICY } from './src/defaults.js';
16
15
  import { findDuplicateTitles, formatDuplicateTitlesMessage, } from './src/titleValidation.js';
17
16
  const SCREENCI_MOCK_RECORD_DOCS_URL = 'https://screenci.com/docs/reference/cli/#--mock-record';
18
- const SCREENCI_LOGIN_DOCS_URL = 'https://screenci.com/docs/reference/cli/#screenci-login';
19
- const SCREENCI_SECRETS_URL = 'https://app.screenci.com/secrets';
17
+ const SCREENCI_RECORD_DOCS_URL = 'https://screenci.com/docs/reference/cli/#screenci-record';
18
+ const SCREENCI_ENVIRONMENT_VARIABLE = 'SCREENCI_ENVIRONMENT';
19
+ const SCREENCI_ENVIRONMENT_OPTION_VALUES = ['local', 'dev', 'prod'];
20
+ const SCREENCI_PRODUCTION_BACKEND_URL = 'https://api.screenci.com';
21
+ const SCREENCI_PRODUCTION_FRONTEND_URL = 'https://app.screenci.com';
22
+ const SCREENCI_DEVELOPMENT_BACKEND_URL = 'https://dev.api.screenci.com';
23
+ const SCREENCI_DEVELOPMENT_FRONTEND_URL = 'https://dev.app.screenci.com';
24
+ const SCREENCI_LINK_SESSION_FILE = 'link-session.json';
25
+ const SCREENCI_LINK_SESSION_POLL_INTERVAL_MS = 2_000;
26
+ const require = createRequire(import.meta.url);
27
+ function parseScreenCIEnvironment(value) {
28
+ if (value === undefined)
29
+ return undefined;
30
+ if (SCREENCI_ENVIRONMENT_OPTION_VALUES.includes(value)) {
31
+ return value;
32
+ }
33
+ throw new Error(`Invalid ${SCREENCI_ENVIRONMENT_VARIABLE} "${value}". Expected one of: ${SCREENCI_ENVIRONMENT_OPTION_VALUES.join(', ')}`);
34
+ }
35
+ function getScreenCIEnvironment() {
36
+ const parsed = parseScreenCIEnvironment(process.env[SCREENCI_ENVIRONMENT_VARIABLE]);
37
+ return parsed ?? 'prod';
38
+ }
20
39
  export function collectPlaywrightListTitles(suites) {
21
40
  const titles = [];
22
41
  const visitSuite = (suite) => {
@@ -51,7 +70,7 @@ function extractPlaywrightDiscoveryError(output) {
51
70
  }
52
71
  }
53
72
  function logScreenCISecretGuide() {
54
- logger.info(`Guide: ${pc.cyan(SCREENCI_LOGIN_DOCS_URL)}`);
73
+ logger.info(`Guide: ${pc.cyan(SCREENCI_RECORD_DOCS_URL)}`);
55
74
  }
56
75
  function getSuggestedScreenciCommand(command) {
57
76
  const pm = determinePackageManager();
@@ -70,7 +89,7 @@ async function collectDiscoveredTestTitles(configPath, additionalArgs, env) {
70
89
  '--list',
71
90
  '--reporter=json',
72
91
  ];
73
- const spawnSpec = resolveSpawnSpec('playwright', listArgs);
92
+ const spawnSpec = resolvePlaywrightSpawnSpec(listArgs, dirname(configPath));
74
93
  return await new Promise((resolve, reject) => {
75
94
  const child = spawn(spawnSpec.command, spawnSpec.args, {
76
95
  stdio: ['inherit', 'pipe', 'pipe'],
@@ -181,6 +200,28 @@ function contentTypeForPath(filePath) {
181
200
  };
182
201
  return contentTypeMap[ext] ?? 'application/octet-stream';
183
202
  }
203
+ /**
204
+ * One-line summary of Studio overrides for CLI output, e.g.
205
+ * `recording.size (1 → 0.8), narration "intro" (en)`.
206
+ */
207
+ export function formatStudioChangeSummary(changes) {
208
+ return changes
209
+ .map((change) => {
210
+ const label = change.label ?? 'selection';
211
+ if (change.kind === 'narration') {
212
+ return change.language !== undefined
213
+ ? `${label} (${change.language})`
214
+ : label;
215
+ }
216
+ return change.from !== undefined && change.to !== undefined
217
+ ? `${label} (${change.from} → ${change.to})`
218
+ : label;
219
+ })
220
+ .join(', ');
221
+ }
222
+ export function formatStudioUrl(appUrl, projectId, videoId) {
223
+ return `${appUrl}/project/${projectId}/video/${videoId}/studio`;
224
+ }
184
225
  class UploadAssetError extends Error {
185
226
  constructor(message) {
186
227
  super(message);
@@ -199,6 +240,14 @@ class PartialUploadError extends Error {
199
240
  this.name = 'PartialUploadError';
200
241
  }
201
242
  }
243
+ class RecordFailureHintError extends Error {
244
+ cause;
245
+ constructor(cause) {
246
+ super(cause.message);
247
+ this.name = cause.name;
248
+ this.cause = cause;
249
+ }
250
+ }
202
251
  function isUploadCancelledError(err) {
203
252
  return (err instanceof UploadCancelledError ||
204
253
  (err instanceof Error &&
@@ -207,12 +256,12 @@ function isUploadCancelledError(err) {
207
256
  function isPartialUploadError(err) {
208
257
  return err instanceof PartialUploadError;
209
258
  }
259
+ function isRecordFailureHintError(err) {
260
+ return err instanceof RecordFailureHintError;
261
+ }
210
262
  function isUploadAssetError(err) {
211
263
  return err instanceof UploadAssetError;
212
264
  }
213
- function supportsInPlaceUploadUpdates(verbose) {
214
- return !verbose && process.stdout.isTTY === true && !process.env.CI;
215
- }
216
265
  function formatUploadProgressLine(videoName, status) {
217
266
  switch (status) {
218
267
  case undefined:
@@ -229,41 +278,13 @@ function formatUploadProgressLine(videoName, status) {
229
278
  }
230
279
  }
231
280
  }
232
- function createUploadProgressReporter(videoNames, verbose) {
233
- const useInPlaceUpdates = supportsInPlaceUploadUpdates(verbose);
234
- if (!useInPlaceUpdates) {
235
- return {
236
- complete(index, status) {
237
- logger.info(formatUploadProgressLine(videoNames[index] ?? 'unknown', status));
238
- },
239
- info(message) {
240
- logger.info(message);
241
- },
242
- };
243
- }
244
- const statuses = new Array(videoNames.length);
245
- let hasRendered = false;
246
- const render = () => {
247
- const renderedLines = videoNames.map((videoName, index) => formatUploadProgressLine(videoName, statuses[index]));
248
- process.stdout.write(`${hasRendered && videoNames.length > 0 ? `\u001B[${videoNames.length}A` : ''}${renderedLines.map((line) => `\r\u001B[2K${line}`).join('\n')}\n`);
249
- hasRendered = true;
250
- };
251
- const clear = () => {
252
- if (!hasRendered || videoNames.length === 0)
253
- return;
254
- process.stdout.write(`\u001B[${videoNames.length}A${Array.from({ length: videoNames.length }, () => '\r\u001B[2K').join('\n')}\n`);
255
- hasRendered = false;
256
- };
257
- render();
281
+ function createUploadProgressReporter(videoNames, _verbose) {
258
282
  return {
259
283
  complete(index, status) {
260
- statuses[index] = status;
261
- render();
284
+ logger.info(formatUploadProgressLine(videoNames[index] ?? 'unknown', status));
262
285
  },
263
286
  info(message) {
264
- clear();
265
287
  logger.info(message);
266
- render();
267
288
  },
268
289
  };
269
290
  }
@@ -305,9 +326,10 @@ async function loadUploadCandidate(screenciDir, entry, verbose) {
305
326
  preparedUploadAssets,
306
327
  };
307
328
  }
308
- async function uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, verbose, uploadAbort, progressReporter, progressIndex) {
329
+ async function uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, elevenLabsApiKey, verbose, uploadAbort, progressReporter, progressIndex, recordId) {
309
330
  const { entry, videoName, data, preparedUploadAssets } = candidate;
310
331
  let projectId = null;
332
+ let videoId = null;
311
333
  try {
312
334
  uploadAbort.throwIfAborted();
313
335
  const recordingPath = resolve(screenciDir, entry, 'recording.mp4');
@@ -315,9 +337,11 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
315
337
  progressReporter.complete(progressIndex, 'failure');
316
338
  return {
317
339
  projectId: null,
340
+ videoId: null,
318
341
  hadFailure: true,
319
342
  videoName,
320
343
  failureMessage: `Missing recording.mp4 for "${videoName}"`,
344
+ recordId,
321
345
  };
322
346
  }
323
347
  const recordingHash = await hashFile(recordingPath);
@@ -326,12 +350,16 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
326
350
  headers: {
327
351
  'Content-Type': 'application/json',
328
352
  'X-ScreenCI-Secret': secret,
353
+ ...(elevenLabsApiKey
354
+ ? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
355
+ : {}),
329
356
  },
330
357
  body: JSON.stringify({
331
358
  projectName,
332
359
  videoName,
333
360
  data,
334
361
  recordingHash,
362
+ recordId,
335
363
  expectedAssets: preparedUploadAssets.map((asset) => ({
336
364
  fileHash: asset.fileHash,
337
365
  size: asset.size,
@@ -349,14 +377,18 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
349
377
  progressReporter.complete(progressIndex, 'failure');
350
378
  return {
351
379
  projectId: null,
380
+ videoId: null,
352
381
  hadFailure: true,
353
382
  videoName,
354
383
  failureMessage: formatUploadStartFailureMessage(videoName, startResponse.status, text, secret),
384
+ recordId,
355
385
  };
356
386
  }
357
387
  const startBody = (await startResponse.json());
358
388
  const { recordingId } = startBody;
359
389
  projectId = startBody.projectId;
390
+ videoId = startBody.videoId ?? null;
391
+ const studio = startBody.studio;
360
392
  if (verbose) {
361
393
  logger.info(`recordingId=${recordingId} projectId=${projectId}`);
362
394
  logger.info(`assets=${preparedUploadAssets.length} recordingHash=${recordingHash ?? 'none'}`);
@@ -381,6 +413,9 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
381
413
  'Content-Type': 'video/mp4',
382
414
  'Content-Length': String(fileStat.size),
383
415
  'X-ScreenCI-Secret': secret,
416
+ ...(elevenLabsApiKey
417
+ ? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
418
+ : {}),
384
419
  },
385
420
  body: stream,
386
421
  signal: uploadAbort.signal,
@@ -392,9 +427,11 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
392
427
  progressReporter.complete(progressIndex, 'failure');
393
428
  return {
394
429
  projectId,
430
+ videoId,
395
431
  hadFailure: true,
396
432
  videoName,
397
433
  failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
434
+ recordId,
398
435
  };
399
436
  }
400
437
  }
@@ -403,7 +440,14 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
403
440
  }
404
441
  progressReporter.complete(progressIndex, 'success');
405
442
  cleanupUploadedRecordingDir(screenciDir, entry);
406
- return { projectId, hadFailure: false, videoName };
443
+ return {
444
+ projectId,
445
+ videoId,
446
+ hadFailure: false,
447
+ videoName,
448
+ recordId,
449
+ ...(studio !== undefined && { studio }),
450
+ };
407
451
  }
408
452
  catch (err) {
409
453
  if (isUploadCancelledError(err)) {
@@ -414,17 +458,21 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
414
458
  progressReporter.complete(progressIndex, 'failure');
415
459
  return {
416
460
  projectId,
461
+ videoId,
417
462
  hadFailure: true,
418
463
  videoName,
419
464
  failureMessage: err instanceof Error ? err.message : String(err),
465
+ recordId,
420
466
  };
421
467
  }
422
468
  progressReporter.complete(progressIndex, 'failure');
423
469
  return {
424
470
  projectId,
471
+ videoId,
425
472
  hadFailure: true,
426
473
  videoName,
427
474
  failureMessage: `Network error uploading "${videoName}": ${err instanceof Error ? err.message : String(err)}`,
475
+ recordId,
428
476
  };
429
477
  }
430
478
  }
@@ -527,6 +575,15 @@ function resolveWindowsCmdShim(cmd) {
527
575
  }
528
576
  return shimName;
529
577
  }
578
+ function resolvePlaywrightSpawnSpec(args, searchFrom) {
579
+ const cliEntrypoint = require.resolve('@playwright/test/cli', {
580
+ paths: [searchFrom],
581
+ });
582
+ return {
583
+ command: process.execPath,
584
+ args: [cliEntrypoint, ...args],
585
+ };
586
+ }
530
587
  function forwardChildSignals(child, activityLabel, options = {}) {
531
588
  let forwardedSignal = null;
532
589
  let forceKillTimer = null;
@@ -594,9 +651,11 @@ function forwardChildSignals(child, activityLabel, options = {}) {
594
651
  getForwardedSignal: () => forwardedSignal,
595
652
  };
596
653
  }
597
- function clearDirectory(dir) {
654
+ function clearRecordingDirectories(dir) {
598
655
  mkdirSync(dir, { recursive: true });
599
656
  for (const entry of readdirSync(dir)) {
657
+ if (entry === SCREENCI_LINK_SESSION_FILE)
658
+ continue;
600
659
  rmSync(resolve(dir, entry), { recursive: true, force: true });
601
660
  }
602
661
  }
@@ -692,11 +751,15 @@ async function prepareCustomVoiceAssets(data, configDir) {
692
751
  }
693
752
  return preparedAssets;
694
753
  }
695
- async function collectUploadAssets(data, configDir) {
754
+ export async function collectUploadAssets(data, configDir) {
696
755
  const sourceFilePath = data.metadata?.sourceFilePath;
697
756
  const assets = new Map();
698
757
  for (const event of data.events) {
699
758
  if (event.type === 'assetStart') {
759
+ // Studio assets have no local file — they are uploaded from the Studio
760
+ // page and merged into the recording by the backend.
761
+ if ('studio' in event && event.studio === true)
762
+ continue;
700
763
  if (assets.has(`name:${event.name}`))
701
764
  continue;
702
765
  const resolvedFile = await readRecordingFile(event.path, configDir, sourceFilePath);
@@ -952,6 +1015,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
952
1015
  }
953
1016
  export async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specificEntry, verbose = false) {
954
1017
  const uploadAbort = createUploadAbortController('upload');
1018
+ const recordId = randomUUID();
955
1019
  let entries;
956
1020
  try {
957
1021
  entries = await readdir(screenciDir);
@@ -960,15 +1024,18 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
960
1024
  logger.warn('No .screenci directory found, skipping upload');
961
1025
  return {
962
1026
  projectId: null,
1027
+ recordId: null,
963
1028
  hadFailures: false,
964
1029
  failedVideoNames: [],
965
1030
  failedVideoMessages: [],
1031
+ studioNotices: [],
966
1032
  };
967
1033
  }
968
1034
  if (specificEntry !== undefined) {
969
1035
  entries = entries.filter((e) => e === specificEntry);
970
1036
  }
971
1037
  let firstProjectId = null;
1038
+ const elevenLabsApiKey = process.env.ELEVENLABS_API_KEY?.trim() || undefined;
972
1039
  try {
973
1040
  const candidates = (await Promise.all(entries.map(async (entry) => {
974
1041
  uploadAbort.throwIfAborted();
@@ -977,13 +1044,15 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
977
1044
  if (candidates.length === 0) {
978
1045
  return {
979
1046
  projectId: null,
1047
+ recordId: null,
980
1048
  hadFailures: false,
981
1049
  failedVideoNames: [],
982
1050
  failedVideoMessages: [],
1051
+ studioNotices: [],
983
1052
  };
984
1053
  }
985
1054
  const progressReporter = createUploadProgressReporter(candidates.map((candidate) => candidate.videoName), verbose);
986
- const results = await Promise.all(candidates.map(async (candidate, index) => await uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, verbose, uploadAbort, progressReporter, index)));
1055
+ const results = await Promise.all(candidates.map(async (candidate, index) => await uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, elevenLabsApiKey, verbose, uploadAbort, progressReporter, index, recordId)));
987
1056
  firstProjectId =
988
1057
  results.find((result) => result.projectId !== null)?.projectId ?? null;
989
1058
  const hadFailures = results.some((result) => result.hadFailure);
@@ -993,11 +1062,22 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
993
1062
  const failedVideoMessages = results.flatMap((result) => result.hadFailure && typeof result.failureMessage === 'string'
994
1063
  ? [{ videoName: result.videoName, message: result.failureMessage }]
995
1064
  : []);
1065
+ const studioNotices = results.flatMap((result) => !result.hadFailure && result.studio !== undefined
1066
+ ? [
1067
+ {
1068
+ videoName: result.videoName,
1069
+ videoId: result.videoId,
1070
+ studio: result.studio,
1071
+ },
1072
+ ]
1073
+ : []);
996
1074
  return {
997
1075
  projectId: firstProjectId,
1076
+ recordId,
998
1077
  hadFailures,
999
1078
  failedVideoNames,
1000
1079
  failedVideoMessages,
1080
+ studioNotices,
1001
1081
  };
1002
1082
  }
1003
1083
  finally {
@@ -1015,16 +1095,38 @@ async function countCompletedRecordings(screenciDir) {
1015
1095
  return entries.filter((entry) => existsSync(resolve(screenciDir, entry, 'data.json'))).length;
1016
1096
  }
1017
1097
  export function getDevBackendUrl() {
1018
- const devBackendPort = process.env.DEV_BACKEND_PORT;
1019
- return devBackendPort
1020
- ? `http://localhost:${devBackendPort}`
1021
- : 'https://api.screenci.com';
1098
+ switch (getScreenCIEnvironment()) {
1099
+ case 'local': {
1100
+ const devBackendPort = process.env.DEV_BACKEND_PORT;
1101
+ return devBackendPort
1102
+ ? `http://localhost:${devBackendPort}`
1103
+ : 'http://localhost:8787';
1104
+ }
1105
+ case 'dev':
1106
+ return SCREENCI_DEVELOPMENT_BACKEND_URL;
1107
+ case 'prod':
1108
+ return SCREENCI_PRODUCTION_BACKEND_URL;
1109
+ }
1022
1110
  }
1023
1111
  export function getDevFrontendUrl() {
1024
- const devFrontendPort = process.env.DEV_FRONTEND_PORT;
1025
- return devFrontendPort
1026
- ? `http://localhost:${devFrontendPort}`
1027
- : 'https://app.screenci.com';
1112
+ switch (getScreenCIEnvironment()) {
1113
+ case 'local': {
1114
+ const devFrontendPort = process.env.DEV_FRONTEND_PORT;
1115
+ return devFrontendPort
1116
+ ? `http://localhost:${devFrontendPort}`
1117
+ : 'http://localhost:5173';
1118
+ }
1119
+ case 'dev':
1120
+ return SCREENCI_DEVELOPMENT_FRONTEND_URL;
1121
+ case 'prod':
1122
+ return SCREENCI_PRODUCTION_FRONTEND_URL;
1123
+ }
1124
+ }
1125
+ export function getCliLinkSessionApiUrl() {
1126
+ return getDevFrontendUrl();
1127
+ }
1128
+ function getScreenCISecretsUrl() {
1129
+ return `${getDevFrontendUrl()}/secrets`;
1028
1130
  }
1029
1131
  async function writeGitHubProjectOutput(projectUrl) {
1030
1132
  const githubOutput = process.env.GITHUB_OUTPUT;
@@ -1234,10 +1336,11 @@ async function loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath) {
1234
1336
  }
1235
1337
  async function requireScreenCISecret(configPath) {
1236
1338
  const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
1237
- const secret = process.env.SCREENCI_SECRET;
1339
+ const secret = process.env.SCREENCI_SECRET ??
1340
+ (await ensureScreenciSecret(resolvedConfigPath));
1238
1341
  if (!secret) {
1239
1342
  const envFilePath = await resolveProjectEnvFilePath(resolvedConfigPath);
1240
- logger.error(`No SCREENCI_SECRET configured. Run ${pc.cyan(getSuggestedScreenciCommand('login'))} or add SCREENCI_SECRET manually to ${envFilePath} by following the guide at ${pc.cyan(SCREENCI_LOGIN_DOCS_URL)}.`);
1343
+ logger.error(`No SCREENCI_SECRET configured. Rerun ${pc.cyan(getSuggestedScreenciCommand('record'))} or add SCREENCI_SECRET manually to ${envFilePath} by following the guide at ${pc.cyan(SCREENCI_RECORD_DOCS_URL)}.`);
1241
1344
  process.exit(1);
1242
1345
  }
1243
1346
  return {
@@ -1281,29 +1384,6 @@ async function updateVideoVisibility(videoId, isPublic, configPath) {
1281
1384
  }
1282
1385
  logger.info(`${isPublic ? 'Made public' : 'Made private'}: ${videoId}`);
1283
1386
  }
1284
- function openBrowser(url) {
1285
- try {
1286
- if (process.platform === 'win32') {
1287
- spawn('cmd', ['/c', 'start', '', url], {
1288
- detached: true,
1289
- stdio: 'ignore',
1290
- shell: true,
1291
- }).unref();
1292
- return;
1293
- }
1294
- const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
1295
- spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
1296
- }
1297
- catch (err) {
1298
- logger.warn('Failed to open browser automatically:', err);
1299
- }
1300
- }
1301
- async function promptToOpenLoginUrlWithSignal(signal) {
1302
- return await confirm({
1303
- message: 'Open this link in your browser now?',
1304
- default: false,
1305
- }, { signal });
1306
- }
1307
1387
  async function persistScreenCISecret(envFilePath, secret) {
1308
1388
  const nextLine = `SCREENCI_SECRET=${secret}`;
1309
1389
  try {
@@ -1330,147 +1410,137 @@ async function persistScreenCISecret(envFilePath, secret) {
1330
1410
  }
1331
1411
  await writeFile(envFilePath, `${nextLine}\n`);
1332
1412
  }
1333
- async function performBrowserLogin(appUrl, options) {
1334
- return new Promise((resolve, reject) => {
1335
- let settled = false;
1336
- const promptAbort = new AbortController();
1337
- const finish = (callback) => {
1338
- if (settled)
1339
- return;
1340
- settled = true;
1341
- promptAbort.abort();
1342
- callback();
1343
- };
1344
- const server = createServer((req, res) => {
1345
- try {
1346
- const reqUrl = new URL(req.url ?? '/', 'http://localhost');
1347
- const secret = reqUrl.searchParams.get('secret');
1348
- if (secret) {
1349
- res.writeHead(200, { 'Content-Type': 'text/html' });
1350
- res.end('<html><body style="font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><p style="font-size:1.2rem">Setup complete! You can close this tab.</p></body></html>');
1351
- server.close();
1352
- finish(() => resolve(secret));
1353
- }
1354
- else {
1355
- res.writeHead(400, { 'Content-Type': 'text/html' });
1356
- res.end('<html><body style="font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><p style="color:red;font-size:1.2rem">Authentication failed: no secret received. Please try again.</p></body></html>');
1357
- server.close();
1358
- finish(() => reject(new Error('No secret received in callback')));
1359
- }
1360
- }
1361
- catch (err) {
1362
- res.writeHead(500);
1363
- res.end('Internal error');
1364
- server.close();
1365
- finish(() => reject(err));
1366
- }
1367
- });
1368
- server.listen(0, '127.0.0.1', () => {
1369
- const port = server.address().port;
1370
- const callbackUrl = `http://localhost:${port}/callback`;
1371
- const loginUrl = `${appUrl}/cli-auth?callback=${encodeURIComponent(callbackUrl)}`;
1372
- logger.info(`Open this link to log in to ScreenCI:\n${loginUrl}\n`);
1373
- void (async () => {
1374
- if (options?.openBrowserImmediately) {
1375
- openBrowser(loginUrl);
1376
- return;
1377
- }
1378
- if (settled)
1379
- return;
1380
- const shouldOpen = await promptToOpenLoginUrlWithSignal(promptAbort.signal);
1381
- if (settled)
1382
- return;
1383
- if (shouldOpen) {
1384
- openBrowser(loginUrl);
1385
- return;
1386
- }
1387
- logger.info('Browser not opened. Keep this command running and open the link manually to continue.');
1388
- })().catch((err) => {
1389
- if (settled)
1390
- return;
1391
- if (err instanceof Error &&
1392
- (err.name === 'AbortPromptError' ||
1393
- err.name === 'AbortError' ||
1394
- err.message === 'Prompt was canceled')) {
1395
- return;
1396
- }
1397
- server.close();
1398
- finish(() => reject(err));
1399
- });
1400
- });
1401
- const timeout = setTimeout(() => {
1402
- server.close();
1403
- finish(() => reject(new Error('Authentication timed out after 15 minutes')));
1404
- }, 15 * 60 * 1000);
1405
- server.on('close', () => clearTimeout(timeout));
1413
+ function getLinkSessionFilePath(projectDir) {
1414
+ return resolve(projectDir, '.screenci', SCREENCI_LINK_SESSION_FILE);
1415
+ }
1416
+ async function readPersistedLinkSessionSpec(specPath) {
1417
+ try {
1418
+ const raw = await readFile(specPath, 'utf-8');
1419
+ return JSON.parse(raw);
1420
+ }
1421
+ catch (error) {
1422
+ if (!isMissingFileError(error)) {
1423
+ logger.warn(`Ignoring invalid stored link session at ${specPath}.`);
1424
+ rmSync(specPath, { force: true });
1425
+ }
1426
+ return null;
1427
+ }
1428
+ }
1429
+ async function writePersistedLinkSessionSpec(specPath, spec) {
1430
+ mkdirSync(dirname(specPath), { recursive: true });
1431
+ await writeFile(specPath, `${JSON.stringify(spec, null, 2)}\n`);
1432
+ }
1433
+ function deletePersistedLinkSessionSpec(specPath) {
1434
+ rmSync(specPath, { force: true });
1435
+ }
1436
+ function isStoredLinkSessionReusable(spec, options) {
1437
+ return (spec.environment === options.environment &&
1438
+ spec.envFilePath === options.envFilePath &&
1439
+ spec.resolvedConfigPath === options.resolvedConfigPath &&
1440
+ spec.expiresAt > new Date().toISOString());
1441
+ }
1442
+ async function createLinkSessionSpec(options) {
1443
+ const response = await fetch(`${options.apiUrl}/cli-link/session`, {
1444
+ method: 'POST',
1406
1445
  });
1446
+ if (!response.ok) {
1447
+ const text = await response.text();
1448
+ throw new Error(`Failed to create link session: ${response.status} ${text}`);
1449
+ }
1450
+ const body = (await response.json());
1451
+ return {
1452
+ token: body.token,
1453
+ appUrl: `${options.appUrl}/cli-auth?session=${encodeURIComponent(body.token)}`,
1454
+ pollUrl: `${options.apiUrl}/cli-link/session?token=${encodeURIComponent(body.token)}`,
1455
+ createdAt: body.createdAt,
1456
+ expiresAt: body.expiresAt,
1457
+ environment: options.environment,
1458
+ ...(options.resolvedConfigPath
1459
+ ? { resolvedConfigPath: options.resolvedConfigPath }
1460
+ : {}),
1461
+ envFilePath: options.envFilePath,
1462
+ };
1463
+ }
1464
+ async function pollLinkSession(spec) {
1465
+ for (;;) {
1466
+ const response = await fetch(spec.pollUrl);
1467
+ const body = (await response.json());
1468
+ const status = body.status ?? 'invalid';
1469
+ if (status === 'completed' && body.secret) {
1470
+ return { status, secret: body.secret };
1471
+ }
1472
+ if (status === 'expired' || status === 'consumed' || status === 'invalid') {
1473
+ return { status };
1474
+ }
1475
+ if (new Date().toISOString() >= spec.expiresAt) {
1476
+ return { status: 'expired' };
1477
+ }
1478
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, SCREENCI_LINK_SESSION_POLL_INTERVAL_MS));
1479
+ }
1407
1480
  }
1408
1481
  export async function ensureScreenciSecret(resolvedConfigPath) {
1409
1482
  const existingSecret = process.env.SCREENCI_SECRET;
1410
1483
  if (existingSecret)
1411
1484
  return existingSecret;
1412
- const appUrl = getDevFrontendUrl();
1413
1485
  try {
1414
- const secret = await performBrowserLogin(appUrl, {
1415
- openBrowserImmediately: true,
1416
- });
1417
- process.env.SCREENCI_SECRET = secret;
1418
- const savePath = resolvedConfigPath
1486
+ const environment = getScreenCIEnvironment();
1487
+ const apiUrl = getCliLinkSessionApiUrl();
1488
+ const appUrl = getDevFrontendUrl();
1489
+ const envFilePath = resolvedConfigPath
1419
1490
  ? await resolveProjectEnvFilePath(resolvedConfigPath)
1420
1491
  : resolve(process.cwd(), '.env');
1421
- await persistScreenCISecret(savePath, secret);
1422
- logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
1423
- return secret;
1492
+ const projectDir = resolvedConfigPath
1493
+ ? dirname(resolvedConfigPath)
1494
+ : process.cwd();
1495
+ const specPath = getLinkSessionFilePath(projectDir);
1496
+ const linkSessionContext = {
1497
+ environment,
1498
+ envFilePath,
1499
+ ...(resolvedConfigPath ? { resolvedConfigPath } : {}),
1500
+ };
1501
+ for (;;) {
1502
+ const storedSpec = await readPersistedLinkSessionSpec(specPath);
1503
+ const spec = storedSpec &&
1504
+ isStoredLinkSessionReusable(storedSpec, linkSessionContext)
1505
+ ? storedSpec
1506
+ : await createLinkSessionSpec({
1507
+ apiUrl,
1508
+ appUrl,
1509
+ ...linkSessionContext,
1510
+ });
1511
+ if (spec !== storedSpec) {
1512
+ await writePersistedLinkSessionSpec(specPath, spec);
1513
+ }
1514
+ logger.info(`Open this link to sign in and connect the CLI:\n${pc.cyan(spec.appUrl)}\n`);
1515
+ const result = await pollLinkSession(spec);
1516
+ if (result.status === 'completed' && result.secret) {
1517
+ process.env.SCREENCI_SECRET = result.secret;
1518
+ await persistScreenCISecret(envFilePath, result.secret);
1519
+ deletePersistedLinkSessionSpec(specPath);
1520
+ logger.info(`Successfully saved SCREENCI_SECRET to ${envFilePath}`);
1521
+ return result.secret;
1522
+ }
1523
+ deletePersistedLinkSessionSpec(specPath);
1524
+ }
1424
1525
  }
1425
1526
  catch (err) {
1426
1527
  const msg = err instanceof Error ? err.message : String(err);
1427
1528
  logger.warn(`Authentication failed: ${msg}`);
1428
- logger.info(`You can add SCREENCI_SECRET manually to .env later. Get it from ${SCREENCI_SECRETS_URL}.`);
1529
+ logger.info(`You can add SCREENCI_SECRET manually to ${resolvedConfigPath ? await resolveProjectEnvFilePath(resolvedConfigPath) : '.env'} later. Get it from ${getScreenCISecretsUrl()}.`);
1429
1530
  logScreenCISecretGuide();
1430
1531
  return undefined;
1431
1532
  }
1432
1533
  }
1433
- async function runLogin(configPath, open = false) {
1434
- const { resolvedConfigPath } = await loadScreenCIConfigAndEnv(configPath);
1435
- if (process.env.SCREENCI_SECRET) {
1436
- logger.info('SCREENCI_SECRET is already configured.');
1437
- return;
1438
- }
1439
- const savePath = await resolveProjectEnvFilePath(resolvedConfigPath);
1440
- const appUrl = getDevFrontendUrl();
1441
- try {
1442
- const secret = await performBrowserLogin(appUrl, {
1443
- openBrowserImmediately: open,
1444
- });
1445
- process.env.SCREENCI_SECRET = secret;
1446
- await persistScreenCISecret(savePath, secret);
1447
- logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
1448
- }
1449
- catch (err) {
1450
- const msg = err instanceof Error ? err.message : String(err);
1451
- logger.warn(`Authentication failed: ${msg}`);
1452
- logger.info(`You can run ${pc.cyan(getSuggestedScreenciCommand('login'))} again or add SCREENCI_SECRET manually to ${savePath}. Get it from ${SCREENCI_SECRETS_URL}.`);
1453
- logScreenCISecretGuide();
1454
- }
1455
- }
1456
1534
  export async function main() {
1457
1535
  if (process.argv.length <= 2) {
1458
1536
  logger.error('Error: No command provided');
1459
- logger.error('Available commands: login, record, test, info, make-public, make-private, init');
1537
+ logger.error('Available commands: record, test, info, make-public, make-private, init');
1460
1538
  process.exit(1);
1461
1539
  }
1462
1540
  const program = new Command();
1463
1541
  const defaultPackageManager = determinePackageManager();
1464
1542
  program.name('screenci');
1465
1543
  program.exitOverride();
1466
- program
1467
- .command('login')
1468
- .description('Authenticate and save SCREENCI_SECRET for this project')
1469
- .option('-c, --config <path>', 'path to screenci.config.ts')
1470
- .option('--open', 'open the login URL in your browser immediately')
1471
- .action(async (options) => {
1472
- await runLogin(options.config, options.open === true);
1473
- });
1474
1544
  // record command — playwright args pass through as-is
1475
1545
  program
1476
1546
  .command('record [playwrightArgs...]')
@@ -1484,13 +1554,13 @@ export async function main() {
1484
1554
  await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose);
1485
1555
  }
1486
1556
  catch (error) {
1487
- logRecordFailureHint();
1488
- if (error instanceof Error &&
1489
- error.message.startsWith('Playwright exited with code ')) {
1490
- playwrightFailure = error;
1557
+ if (!(error instanceof Error))
1558
+ throw error;
1559
+ if (error.message.startsWith('Playwright exited with code ')) {
1560
+ playwrightFailure = new RecordFailureHintError(error);
1491
1561
  }
1492
1562
  else {
1493
- throw error;
1563
+ throw new RecordFailureHintError(error);
1494
1564
  }
1495
1565
  }
1496
1566
  if (process.env.SCREENCI_RECORDING === 'true')
@@ -1514,7 +1584,7 @@ export async function main() {
1514
1584
  logger.info('All recordings failed.');
1515
1585
  }
1516
1586
  else if (!secret) {
1517
- logger.info(`No SCREENCI_SECRET configured for uploads. Run ${getSuggestedScreenciCommand('login')} or add it to the project env file.`);
1587
+ logger.info(`No SCREENCI_SECRET configured for uploads. Rerun ${getSuggestedScreenciCommand('record')} or add it to the project env file.`);
1518
1588
  }
1519
1589
  else if (playwrightFailure !== null &&
1520
1590
  uploadPolicy === 'all-or-nothing') {
@@ -1526,9 +1596,11 @@ export async function main() {
1526
1596
  }
1527
1597
  let uploadResult = {
1528
1598
  projectId: null,
1599
+ recordId: null,
1529
1600
  hadFailures: false,
1530
1601
  failedVideoNames: [],
1531
1602
  failedVideoMessages: [],
1603
+ studioNotices: [],
1532
1604
  };
1533
1605
  try {
1534
1606
  uploadResult = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret);
@@ -1539,8 +1611,17 @@ export async function main() {
1539
1611
  }
1540
1612
  throw err;
1541
1613
  }
1542
- const { projectId, hadFailures, failedVideoNames, failedVideoMessages, } = uploadResult;
1543
- if (projectId !== null) {
1614
+ const { projectId, recordId, hadFailures, failedVideoNames, failedVideoMessages, studioNotices, } = uploadResult;
1615
+ if (recordId !== null && projectId !== null) {
1616
+ const recordUrl = `${appUrl}/record/${recordId}`;
1617
+ await writeGitHubProjectOutput(recordUrl);
1618
+ logger.info('');
1619
+ logger.info(playwrightFailure !== null
1620
+ ? 'Recording partially succeeded, rendering in progress. Results available at:'
1621
+ : 'Recording finished, rendering in progress. Results available at:');
1622
+ logger.info(pc.cyan(recordUrl));
1623
+ }
1624
+ else if (projectId !== null) {
1544
1625
  const projectUrl = `${appUrl}/project/${projectId}`;
1545
1626
  await writeGitHubProjectOutput(projectUrl);
1546
1627
  logger.info('');
@@ -1549,6 +1630,19 @@ export async function main() {
1549
1630
  : 'Recording finished, rendering in progress. Results available at:');
1550
1631
  logger.info(pc.cyan(projectUrl));
1551
1632
  }
1633
+ for (const notice of studioNotices) {
1634
+ if ('held' in notice.studio) {
1635
+ logger.info('');
1636
+ logger.info(`Rendering for "${notice.videoName}" is on hold — configure it in Studio:`);
1637
+ if (projectId !== null && notice.videoId !== null) {
1638
+ logger.info(pc.cyan(formatStudioUrl(appUrl, projectId, notice.videoId)));
1639
+ }
1640
+ }
1641
+ else if (notice.studio.appliedChanges.length > 0) {
1642
+ logger.info('');
1643
+ logger.info(`Selections were overridden in Studio for "${notice.videoName}": ${formatStudioChangeSummary(notice.studio.appliedChanges)}`);
1644
+ }
1645
+ }
1552
1646
  if (hadFailures) {
1553
1647
  for (const failedVideo of failedVideoMessages) {
1554
1648
  logger.warn(formatFailedVideoMessage(failedVideo.videoName, failedVideo.message));
@@ -1770,7 +1864,7 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
1770
1864
  }
1771
1865
  validateArgs(additionalArgs);
1772
1866
  const screenciDir = resolve(dirname(configPath), '.screenci');
1773
- clearDirectory(screenciDir);
1867
+ clearRecordingDirectories(screenciDir);
1774
1868
  }
1775
1869
  const envForChild = { ...process.env };
1776
1870
  await validateUniqueDiscoveredTestTitles(configPath, additionalArgs, {
@@ -1788,7 +1882,7 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
1788
1882
  logger.info(`Using config: ${configPath}`);
1789
1883
  }
1790
1884
  const playwrightArgs = ['test', '--config', configPath, ...additionalArgs];
1791
- const spawnSpec = resolveSpawnSpec('playwright', playwrightArgs);
1885
+ const spawnSpec = resolvePlaywrightSpawnSpec(playwrightArgs, dirname(configPath));
1792
1886
  const child = spawn(spawnSpec.command, spawnSpec.args, {
1793
1887
  stdio: 'inherit',
1794
1888
  ...(process.platform !== 'win32' ? { detached: true } : {}),
@@ -1843,9 +1937,21 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
1843
1937
  });
1844
1938
  }
1845
1939
  function logRecordFailureHint() {
1940
+ logger.info('');
1846
1941
  logger.info(`If ${pc.cyan('screenci test')} works but ${pc.cyan('screenci record')} fails, try ${pc.cyan('screenci test --mock-record')}.`);
1847
1942
  logger.info(`More info: ${pc.cyan(SCREENCI_MOCK_RECORD_DOCS_URL)}`);
1848
1943
  }
1944
+ export function logCliError(error) {
1945
+ if (isPartialUploadError(error)) {
1946
+ return;
1947
+ }
1948
+ const errorToLog = isRecordFailureHintError(error) ? error.cause : error;
1949
+ const message = errorToLog instanceof Error ? errorToLog.message : String(errorToLog);
1950
+ logger.error(message);
1951
+ if (isRecordFailureHintError(error)) {
1952
+ logRecordFailureHint();
1953
+ }
1954
+ }
1849
1955
  // Only run if this file is being executed directly
1850
1956
  // Check if this module is the main module (handles symlinks properly)
1851
1957
  const currentFile = fileURLToPath(import.meta.url);
@@ -1856,10 +1962,7 @@ if (mainFile &&
1856
1962
  currentRealFile === mainFile ||
1857
1963
  currentFile === realpathSync(mainFile))) {
1858
1964
  main().catch((error) => {
1859
- if (isPartialUploadError(error)) {
1860
- process.exit(1);
1861
- }
1862
- logger.error('Error:', error.message);
1965
+ logCliError(error);
1863
1966
  process.exit(1);
1864
1967
  });
1865
1968
  }