screenci 0.0.61 → 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 (68) 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 +318 -212
  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 +12 -3
  37. package/dist/src/init.d.ts.map +1 -1
  38. package/dist/src/init.js +248 -54
  39. package/dist/src/init.js.map +1 -1
  40. package/dist/src/instrument.d.ts.map +1 -1
  41. package/dist/src/instrument.js +49 -125
  42. package/dist/src/instrument.js.map +1 -1
  43. package/dist/src/mouse.d.ts +1 -0
  44. package/dist/src/mouse.d.ts.map +1 -1
  45. package/dist/src/mouse.js +9 -3
  46. package/dist/src/mouse.js.map +1 -1
  47. package/dist/src/recording.d.ts +1 -1
  48. package/dist/src/recording.d.ts.map +1 -1
  49. package/dist/src/recordingData.d.ts +43 -1
  50. package/dist/src/recordingData.d.ts.map +1 -1
  51. package/dist/src/studio.d.ts +36 -0
  52. package/dist/src/studio.d.ts.map +1 -0
  53. package/dist/src/studio.js +39 -0
  54. package/dist/src/studio.js.map +1 -0
  55. package/dist/src/types.d.ts +141 -125
  56. package/dist/src/types.d.ts.map +1 -1
  57. package/dist/src/types.js +1 -0
  58. package/dist/src/types.js.map +1 -1
  59. package/dist/src/video.d.ts +2 -1
  60. package/dist/src/video.d.ts.map +1 -1
  61. package/dist/src/video.js.map +1 -1
  62. package/dist/src/voices.d.ts +3 -3
  63. package/dist/src/voices.d.ts.map +1 -1
  64. package/dist/tsconfig.tsbuildinfo +1 -1
  65. package/package.json +1 -1
  66. package/skills/screenci/SKILL.md +4 -7
  67. package/skills/screenci/references/init.md +1 -2
  68. 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,12 +70,15 @@ 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
- return determinePackageManager() === 'pnpm'
58
- ? `pnpm exec screenci ${command}`
59
- : `npx screenci ${command}`;
76
+ const pm = determinePackageManager();
77
+ if (pm === 'pnpm')
78
+ return `pnpm exec screenci ${command}`;
79
+ if (pm === 'yarn')
80
+ return `yarn screenci ${command}`;
81
+ return `npx screenci ${command}`;
60
82
  }
61
83
  async function collectDiscoveredTestTitles(configPath, additionalArgs, env) {
62
84
  const listArgs = [
@@ -67,7 +89,7 @@ async function collectDiscoveredTestTitles(configPath, additionalArgs, env) {
67
89
  '--list',
68
90
  '--reporter=json',
69
91
  ];
70
- const spawnSpec = resolveSpawnSpec('playwright', listArgs);
92
+ const spawnSpec = resolvePlaywrightSpawnSpec(listArgs, dirname(configPath));
71
93
  return await new Promise((resolve, reject) => {
72
94
  const child = spawn(spawnSpec.command, spawnSpec.args, {
73
95
  stdio: ['inherit', 'pipe', 'pipe'],
@@ -178,6 +200,28 @@ function contentTypeForPath(filePath) {
178
200
  };
179
201
  return contentTypeMap[ext] ?? 'application/octet-stream';
180
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
+ }
181
225
  class UploadAssetError extends Error {
182
226
  constructor(message) {
183
227
  super(message);
@@ -196,6 +240,14 @@ class PartialUploadError extends Error {
196
240
  this.name = 'PartialUploadError';
197
241
  }
198
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
+ }
199
251
  function isUploadCancelledError(err) {
200
252
  return (err instanceof UploadCancelledError ||
201
253
  (err instanceof Error &&
@@ -204,12 +256,12 @@ function isUploadCancelledError(err) {
204
256
  function isPartialUploadError(err) {
205
257
  return err instanceof PartialUploadError;
206
258
  }
259
+ function isRecordFailureHintError(err) {
260
+ return err instanceof RecordFailureHintError;
261
+ }
207
262
  function isUploadAssetError(err) {
208
263
  return err instanceof UploadAssetError;
209
264
  }
210
- function supportsInPlaceUploadUpdates(verbose) {
211
- return !verbose && process.stdout.isTTY === true && !process.env.CI;
212
- }
213
265
  function formatUploadProgressLine(videoName, status) {
214
266
  switch (status) {
215
267
  case undefined:
@@ -226,41 +278,13 @@ function formatUploadProgressLine(videoName, status) {
226
278
  }
227
279
  }
228
280
  }
229
- function createUploadProgressReporter(videoNames, verbose) {
230
- const useInPlaceUpdates = supportsInPlaceUploadUpdates(verbose);
231
- if (!useInPlaceUpdates) {
232
- return {
233
- complete(index, status) {
234
- logger.info(formatUploadProgressLine(videoNames[index] ?? 'unknown', status));
235
- },
236
- info(message) {
237
- logger.info(message);
238
- },
239
- };
240
- }
241
- const statuses = new Array(videoNames.length);
242
- let hasRendered = false;
243
- const render = () => {
244
- const renderedLines = videoNames.map((videoName, index) => formatUploadProgressLine(videoName, statuses[index]));
245
- process.stdout.write(`${hasRendered && videoNames.length > 0 ? `\u001B[${videoNames.length}A` : ''}${renderedLines.map((line) => `\r\u001B[2K${line}`).join('\n')}\n`);
246
- hasRendered = true;
247
- };
248
- const clear = () => {
249
- if (!hasRendered || videoNames.length === 0)
250
- return;
251
- process.stdout.write(`\u001B[${videoNames.length}A${Array.from({ length: videoNames.length }, () => '\r\u001B[2K').join('\n')}\n`);
252
- hasRendered = false;
253
- };
254
- render();
281
+ function createUploadProgressReporter(videoNames, _verbose) {
255
282
  return {
256
283
  complete(index, status) {
257
- statuses[index] = status;
258
- render();
284
+ logger.info(formatUploadProgressLine(videoNames[index] ?? 'unknown', status));
259
285
  },
260
286
  info(message) {
261
- clear();
262
287
  logger.info(message);
263
- render();
264
288
  },
265
289
  };
266
290
  }
@@ -302,9 +326,10 @@ async function loadUploadCandidate(screenciDir, entry, verbose) {
302
326
  preparedUploadAssets,
303
327
  };
304
328
  }
305
- 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) {
306
330
  const { entry, videoName, data, preparedUploadAssets } = candidate;
307
331
  let projectId = null;
332
+ let videoId = null;
308
333
  try {
309
334
  uploadAbort.throwIfAborted();
310
335
  const recordingPath = resolve(screenciDir, entry, 'recording.mp4');
@@ -312,9 +337,11 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
312
337
  progressReporter.complete(progressIndex, 'failure');
313
338
  return {
314
339
  projectId: null,
340
+ videoId: null,
315
341
  hadFailure: true,
316
342
  videoName,
317
343
  failureMessage: `Missing recording.mp4 for "${videoName}"`,
344
+ recordId,
318
345
  };
319
346
  }
320
347
  const recordingHash = await hashFile(recordingPath);
@@ -323,12 +350,16 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
323
350
  headers: {
324
351
  'Content-Type': 'application/json',
325
352
  'X-ScreenCI-Secret': secret,
353
+ ...(elevenLabsApiKey
354
+ ? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
355
+ : {}),
326
356
  },
327
357
  body: JSON.stringify({
328
358
  projectName,
329
359
  videoName,
330
360
  data,
331
361
  recordingHash,
362
+ recordId,
332
363
  expectedAssets: preparedUploadAssets.map((asset) => ({
333
364
  fileHash: asset.fileHash,
334
365
  size: asset.size,
@@ -346,14 +377,18 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
346
377
  progressReporter.complete(progressIndex, 'failure');
347
378
  return {
348
379
  projectId: null,
380
+ videoId: null,
349
381
  hadFailure: true,
350
382
  videoName,
351
383
  failureMessage: formatUploadStartFailureMessage(videoName, startResponse.status, text, secret),
384
+ recordId,
352
385
  };
353
386
  }
354
387
  const startBody = (await startResponse.json());
355
388
  const { recordingId } = startBody;
356
389
  projectId = startBody.projectId;
390
+ videoId = startBody.videoId ?? null;
391
+ const studio = startBody.studio;
357
392
  if (verbose) {
358
393
  logger.info(`recordingId=${recordingId} projectId=${projectId}`);
359
394
  logger.info(`assets=${preparedUploadAssets.length} recordingHash=${recordingHash ?? 'none'}`);
@@ -378,6 +413,9 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
378
413
  'Content-Type': 'video/mp4',
379
414
  'Content-Length': String(fileStat.size),
380
415
  'X-ScreenCI-Secret': secret,
416
+ ...(elevenLabsApiKey
417
+ ? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
418
+ : {}),
381
419
  },
382
420
  body: stream,
383
421
  signal: uploadAbort.signal,
@@ -389,9 +427,11 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
389
427
  progressReporter.complete(progressIndex, 'failure');
390
428
  return {
391
429
  projectId,
430
+ videoId,
392
431
  hadFailure: true,
393
432
  videoName,
394
433
  failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
434
+ recordId,
395
435
  };
396
436
  }
397
437
  }
@@ -400,7 +440,14 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
400
440
  }
401
441
  progressReporter.complete(progressIndex, 'success');
402
442
  cleanupUploadedRecordingDir(screenciDir, entry);
403
- return { projectId, hadFailure: false, videoName };
443
+ return {
444
+ projectId,
445
+ videoId,
446
+ hadFailure: false,
447
+ videoName,
448
+ recordId,
449
+ ...(studio !== undefined && { studio }),
450
+ };
404
451
  }
405
452
  catch (err) {
406
453
  if (isUploadCancelledError(err)) {
@@ -411,17 +458,21 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
411
458
  progressReporter.complete(progressIndex, 'failure');
412
459
  return {
413
460
  projectId,
461
+ videoId,
414
462
  hadFailure: true,
415
463
  videoName,
416
464
  failureMessage: err instanceof Error ? err.message : String(err),
465
+ recordId,
417
466
  };
418
467
  }
419
468
  progressReporter.complete(progressIndex, 'failure');
420
469
  return {
421
470
  projectId,
471
+ videoId,
422
472
  hadFailure: true,
423
473
  videoName,
424
474
  failureMessage: `Network error uploading "${videoName}": ${err instanceof Error ? err.message : String(err)}`,
475
+ recordId,
425
476
  };
426
477
  }
427
478
  }
@@ -524,6 +575,15 @@ function resolveWindowsCmdShim(cmd) {
524
575
  }
525
576
  return shimName;
526
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
+ }
527
587
  function forwardChildSignals(child, activityLabel, options = {}) {
528
588
  let forwardedSignal = null;
529
589
  let forceKillTimer = null;
@@ -591,9 +651,11 @@ function forwardChildSignals(child, activityLabel, options = {}) {
591
651
  getForwardedSignal: () => forwardedSignal,
592
652
  };
593
653
  }
594
- function clearDirectory(dir) {
654
+ function clearRecordingDirectories(dir) {
595
655
  mkdirSync(dir, { recursive: true });
596
656
  for (const entry of readdirSync(dir)) {
657
+ if (entry === SCREENCI_LINK_SESSION_FILE)
658
+ continue;
597
659
  rmSync(resolve(dir, entry), { recursive: true, force: true });
598
660
  }
599
661
  }
@@ -689,11 +751,15 @@ async function prepareCustomVoiceAssets(data, configDir) {
689
751
  }
690
752
  return preparedAssets;
691
753
  }
692
- async function collectUploadAssets(data, configDir) {
754
+ export async function collectUploadAssets(data, configDir) {
693
755
  const sourceFilePath = data.metadata?.sourceFilePath;
694
756
  const assets = new Map();
695
757
  for (const event of data.events) {
696
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;
697
763
  if (assets.has(`name:${event.name}`))
698
764
  continue;
699
765
  const resolvedFile = await readRecordingFile(event.path, configDir, sourceFilePath);
@@ -949,6 +1015,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
949
1015
  }
950
1016
  export async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specificEntry, verbose = false) {
951
1017
  const uploadAbort = createUploadAbortController('upload');
1018
+ const recordId = randomUUID();
952
1019
  let entries;
953
1020
  try {
954
1021
  entries = await readdir(screenciDir);
@@ -957,15 +1024,18 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
957
1024
  logger.warn('No .screenci directory found, skipping upload');
958
1025
  return {
959
1026
  projectId: null,
1027
+ recordId: null,
960
1028
  hadFailures: false,
961
1029
  failedVideoNames: [],
962
1030
  failedVideoMessages: [],
1031
+ studioNotices: [],
963
1032
  };
964
1033
  }
965
1034
  if (specificEntry !== undefined) {
966
1035
  entries = entries.filter((e) => e === specificEntry);
967
1036
  }
968
1037
  let firstProjectId = null;
1038
+ const elevenLabsApiKey = process.env.ELEVENLABS_API_KEY?.trim() || undefined;
969
1039
  try {
970
1040
  const candidates = (await Promise.all(entries.map(async (entry) => {
971
1041
  uploadAbort.throwIfAborted();
@@ -974,13 +1044,15 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
974
1044
  if (candidates.length === 0) {
975
1045
  return {
976
1046
  projectId: null,
1047
+ recordId: null,
977
1048
  hadFailures: false,
978
1049
  failedVideoNames: [],
979
1050
  failedVideoMessages: [],
1051
+ studioNotices: [],
980
1052
  };
981
1053
  }
982
1054
  const progressReporter = createUploadProgressReporter(candidates.map((candidate) => candidate.videoName), verbose);
983
- 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)));
984
1056
  firstProjectId =
985
1057
  results.find((result) => result.projectId !== null)?.projectId ?? null;
986
1058
  const hadFailures = results.some((result) => result.hadFailure);
@@ -990,11 +1062,22 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
990
1062
  const failedVideoMessages = results.flatMap((result) => result.hadFailure && typeof result.failureMessage === 'string'
991
1063
  ? [{ videoName: result.videoName, message: result.failureMessage }]
992
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
+ : []);
993
1074
  return {
994
1075
  projectId: firstProjectId,
1076
+ recordId,
995
1077
  hadFailures,
996
1078
  failedVideoNames,
997
1079
  failedVideoMessages,
1080
+ studioNotices,
998
1081
  };
999
1082
  }
1000
1083
  finally {
@@ -1012,16 +1095,38 @@ async function countCompletedRecordings(screenciDir) {
1012
1095
  return entries.filter((entry) => existsSync(resolve(screenciDir, entry, 'data.json'))).length;
1013
1096
  }
1014
1097
  export function getDevBackendUrl() {
1015
- const devBackendPort = process.env.DEV_BACKEND_PORT;
1016
- return devBackendPort
1017
- ? `http://localhost:${devBackendPort}`
1018
- : '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
+ }
1019
1110
  }
1020
1111
  export function getDevFrontendUrl() {
1021
- const devFrontendPort = process.env.DEV_FRONTEND_PORT;
1022
- return devFrontendPort
1023
- ? `http://localhost:${devFrontendPort}`
1024
- : '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`;
1025
1130
  }
1026
1131
  async function writeGitHubProjectOutput(projectUrl) {
1027
1132
  const githubOutput = process.env.GITHUB_OUTPUT;
@@ -1231,10 +1336,11 @@ async function loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath) {
1231
1336
  }
1232
1337
  async function requireScreenCISecret(configPath) {
1233
1338
  const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
1234
- const secret = process.env.SCREENCI_SECRET;
1339
+ const secret = process.env.SCREENCI_SECRET ??
1340
+ (await ensureScreenciSecret(resolvedConfigPath));
1235
1341
  if (!secret) {
1236
1342
  const envFilePath = await resolveProjectEnvFilePath(resolvedConfigPath);
1237
- 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)}.`);
1238
1344
  process.exit(1);
1239
1345
  }
1240
1346
  return {
@@ -1278,29 +1384,6 @@ async function updateVideoVisibility(videoId, isPublic, configPath) {
1278
1384
  }
1279
1385
  logger.info(`${isPublic ? 'Made public' : 'Made private'}: ${videoId}`);
1280
1386
  }
1281
- function openBrowser(url) {
1282
- try {
1283
- if (process.platform === 'win32') {
1284
- spawn('cmd', ['/c', 'start', '', url], {
1285
- detached: true,
1286
- stdio: 'ignore',
1287
- shell: true,
1288
- }).unref();
1289
- return;
1290
- }
1291
- const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
1292
- spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
1293
- }
1294
- catch (err) {
1295
- logger.warn('Failed to open browser automatically:', err);
1296
- }
1297
- }
1298
- async function promptToOpenLoginUrlWithSignal(signal) {
1299
- return await confirm({
1300
- message: 'Open this link in your browser now?',
1301
- default: false,
1302
- }, { signal });
1303
- }
1304
1387
  async function persistScreenCISecret(envFilePath, secret) {
1305
1388
  const nextLine = `SCREENCI_SECRET=${secret}`;
1306
1389
  try {
@@ -1327,147 +1410,137 @@ async function persistScreenCISecret(envFilePath, secret) {
1327
1410
  }
1328
1411
  await writeFile(envFilePath, `${nextLine}\n`);
1329
1412
  }
1330
- async function performBrowserLogin(appUrl, options) {
1331
- return new Promise((resolve, reject) => {
1332
- let settled = false;
1333
- const promptAbort = new AbortController();
1334
- const finish = (callback) => {
1335
- if (settled)
1336
- return;
1337
- settled = true;
1338
- promptAbort.abort();
1339
- callback();
1340
- };
1341
- const server = createServer((req, res) => {
1342
- try {
1343
- const reqUrl = new URL(req.url ?? '/', 'http://localhost');
1344
- const secret = reqUrl.searchParams.get('secret');
1345
- if (secret) {
1346
- res.writeHead(200, { 'Content-Type': 'text/html' });
1347
- 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>');
1348
- server.close();
1349
- finish(() => resolve(secret));
1350
- }
1351
- else {
1352
- res.writeHead(400, { 'Content-Type': 'text/html' });
1353
- 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>');
1354
- server.close();
1355
- finish(() => reject(new Error('No secret received in callback')));
1356
- }
1357
- }
1358
- catch (err) {
1359
- res.writeHead(500);
1360
- res.end('Internal error');
1361
- server.close();
1362
- finish(() => reject(err));
1363
- }
1364
- });
1365
- server.listen(0, '127.0.0.1', () => {
1366
- const port = server.address().port;
1367
- const callbackUrl = `http://localhost:${port}/callback`;
1368
- const loginUrl = `${appUrl}/cli-auth?callback=${encodeURIComponent(callbackUrl)}`;
1369
- logger.info(`Open this link to log in to ScreenCI:\n${loginUrl}\n`);
1370
- void (async () => {
1371
- if (options?.openBrowserImmediately) {
1372
- openBrowser(loginUrl);
1373
- return;
1374
- }
1375
- if (settled)
1376
- return;
1377
- const shouldOpen = await promptToOpenLoginUrlWithSignal(promptAbort.signal);
1378
- if (settled)
1379
- return;
1380
- if (shouldOpen) {
1381
- openBrowser(loginUrl);
1382
- return;
1383
- }
1384
- logger.info('Browser not opened. Keep this command running and open the link manually to continue.');
1385
- })().catch((err) => {
1386
- if (settled)
1387
- return;
1388
- if (err instanceof Error &&
1389
- (err.name === 'AbortPromptError' ||
1390
- err.name === 'AbortError' ||
1391
- err.message === 'Prompt was canceled')) {
1392
- return;
1393
- }
1394
- server.close();
1395
- finish(() => reject(err));
1396
- });
1397
- });
1398
- const timeout = setTimeout(() => {
1399
- server.close();
1400
- finish(() => reject(new Error('Authentication timed out after 15 minutes')));
1401
- }, 15 * 60 * 1000);
1402
- 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',
1403
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
+ }
1404
1480
  }
1405
1481
  export async function ensureScreenciSecret(resolvedConfigPath) {
1406
1482
  const existingSecret = process.env.SCREENCI_SECRET;
1407
1483
  if (existingSecret)
1408
1484
  return existingSecret;
1409
- const appUrl = getDevFrontendUrl();
1410
1485
  try {
1411
- const secret = await performBrowserLogin(appUrl, {
1412
- openBrowserImmediately: true,
1413
- });
1414
- process.env.SCREENCI_SECRET = secret;
1415
- const savePath = resolvedConfigPath
1486
+ const environment = getScreenCIEnvironment();
1487
+ const apiUrl = getCliLinkSessionApiUrl();
1488
+ const appUrl = getDevFrontendUrl();
1489
+ const envFilePath = resolvedConfigPath
1416
1490
  ? await resolveProjectEnvFilePath(resolvedConfigPath)
1417
1491
  : resolve(process.cwd(), '.env');
1418
- await persistScreenCISecret(savePath, secret);
1419
- logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
1420
- 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
+ }
1421
1525
  }
1422
1526
  catch (err) {
1423
1527
  const msg = err instanceof Error ? err.message : String(err);
1424
1528
  logger.warn(`Authentication failed: ${msg}`);
1425
- 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()}.`);
1426
1530
  logScreenCISecretGuide();
1427
1531
  return undefined;
1428
1532
  }
1429
1533
  }
1430
- async function runLogin(configPath, open = false) {
1431
- const { resolvedConfigPath } = await loadScreenCIConfigAndEnv(configPath);
1432
- if (process.env.SCREENCI_SECRET) {
1433
- logger.info('SCREENCI_SECRET is already configured.');
1434
- return;
1435
- }
1436
- const savePath = await resolveProjectEnvFilePath(resolvedConfigPath);
1437
- const appUrl = getDevFrontendUrl();
1438
- try {
1439
- const secret = await performBrowserLogin(appUrl, {
1440
- openBrowserImmediately: open,
1441
- });
1442
- process.env.SCREENCI_SECRET = secret;
1443
- await persistScreenCISecret(savePath, secret);
1444
- logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
1445
- }
1446
- catch (err) {
1447
- const msg = err instanceof Error ? err.message : String(err);
1448
- logger.warn(`Authentication failed: ${msg}`);
1449
- logger.info(`You can run ${pc.cyan(getSuggestedScreenciCommand('login'))} again or add SCREENCI_SECRET manually to ${savePath}. Get it from ${SCREENCI_SECRETS_URL}.`);
1450
- logScreenCISecretGuide();
1451
- }
1452
- }
1453
1534
  export async function main() {
1454
1535
  if (process.argv.length <= 2) {
1455
1536
  logger.error('Error: No command provided');
1456
- 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');
1457
1538
  process.exit(1);
1458
1539
  }
1459
1540
  const program = new Command();
1460
1541
  const defaultPackageManager = determinePackageManager();
1461
1542
  program.name('screenci');
1462
1543
  program.exitOverride();
1463
- program
1464
- .command('login')
1465
- .description('Authenticate and save SCREENCI_SECRET for this project')
1466
- .option('-c, --config <path>', 'path to screenci.config.ts')
1467
- .option('--open', 'open the login URL in your browser immediately')
1468
- .action(async (options) => {
1469
- await runLogin(options.config, options.open === true);
1470
- });
1471
1544
  // record command — playwright args pass through as-is
1472
1545
  program
1473
1546
  .command('record [playwrightArgs...]')
@@ -1481,13 +1554,13 @@ export async function main() {
1481
1554
  await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose);
1482
1555
  }
1483
1556
  catch (error) {
1484
- logRecordFailureHint();
1485
- if (error instanceof Error &&
1486
- error.message.startsWith('Playwright exited with code ')) {
1487
- playwrightFailure = error;
1557
+ if (!(error instanceof Error))
1558
+ throw error;
1559
+ if (error.message.startsWith('Playwright exited with code ')) {
1560
+ playwrightFailure = new RecordFailureHintError(error);
1488
1561
  }
1489
1562
  else {
1490
- throw error;
1563
+ throw new RecordFailureHintError(error);
1491
1564
  }
1492
1565
  }
1493
1566
  if (process.env.SCREENCI_RECORDING === 'true')
@@ -1511,7 +1584,7 @@ export async function main() {
1511
1584
  logger.info('All recordings failed.');
1512
1585
  }
1513
1586
  else if (!secret) {
1514
- 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.`);
1515
1588
  }
1516
1589
  else if (playwrightFailure !== null &&
1517
1590
  uploadPolicy === 'all-or-nothing') {
@@ -1523,9 +1596,11 @@ export async function main() {
1523
1596
  }
1524
1597
  let uploadResult = {
1525
1598
  projectId: null,
1599
+ recordId: null,
1526
1600
  hadFailures: false,
1527
1601
  failedVideoNames: [],
1528
1602
  failedVideoMessages: [],
1603
+ studioNotices: [],
1529
1604
  };
1530
1605
  try {
1531
1606
  uploadResult = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret);
@@ -1536,8 +1611,17 @@ export async function main() {
1536
1611
  }
1537
1612
  throw err;
1538
1613
  }
1539
- const { projectId, hadFailures, failedVideoNames, failedVideoMessages, } = uploadResult;
1540
- 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) {
1541
1625
  const projectUrl = `${appUrl}/project/${projectId}`;
1542
1626
  await writeGitHubProjectOutput(projectUrl);
1543
1627
  logger.info('');
@@ -1546,6 +1630,19 @@ export async function main() {
1546
1630
  : 'Recording finished, rendering in progress. Results available at:');
1547
1631
  logger.info(pc.cyan(projectUrl));
1548
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
+ }
1549
1646
  if (hadFailures) {
1550
1647
  for (const failedVideo of failedVideoMessages) {
1551
1648
  logger.warn(formatFailedVideoMessage(failedVideo.videoName, failedVideo.message));
@@ -1623,7 +1720,7 @@ export async function main() {
1623
1720
  .command('init [name]')
1624
1721
  .description('Initialize a new screenci project')
1625
1722
  .option('--agent <name>', 'target agent for skills install, e.g. opencode. Supported agents: https://github.com/vercel-labs/skills#supported-agents')
1626
- .option('--package-manager <manager>', `package manager to use: npm or pnpm (default: ${defaultPackageManager})`)
1723
+ .option('--package-manager <manager>', `package manager to use: npm, pnpm, or yarn 2+ (default: ${defaultPackageManager})`)
1627
1724
  .option('-y, --yes', 'accept init defaults')
1628
1725
  .option('-v, --verbose', 'verbose output')
1629
1726
  .action(async (name, options) => {
@@ -1631,7 +1728,7 @@ export async function main() {
1631
1728
  await runInit(name, {
1632
1729
  verbose: options['verbose'] ?? false,
1633
1730
  yes: options['yes'] ?? false,
1634
- packageManager: parsePackageManager(options['packageManager']),
1731
+ packageManager: parsePackageManager(options['packageManager'], process.env['SCREENCI_INIT_CWD'] ?? process.cwd()),
1635
1732
  ...(agent !== undefined ? { agent } : {}),
1636
1733
  });
1637
1734
  });
@@ -1767,7 +1864,7 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
1767
1864
  }
1768
1865
  validateArgs(additionalArgs);
1769
1866
  const screenciDir = resolve(dirname(configPath), '.screenci');
1770
- clearDirectory(screenciDir);
1867
+ clearRecordingDirectories(screenciDir);
1771
1868
  }
1772
1869
  const envForChild = { ...process.env };
1773
1870
  await validateUniqueDiscoveredTestTitles(configPath, additionalArgs, {
@@ -1785,7 +1882,7 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
1785
1882
  logger.info(`Using config: ${configPath}`);
1786
1883
  }
1787
1884
  const playwrightArgs = ['test', '--config', configPath, ...additionalArgs];
1788
- const spawnSpec = resolveSpawnSpec('playwright', playwrightArgs);
1885
+ const spawnSpec = resolvePlaywrightSpawnSpec(playwrightArgs, dirname(configPath));
1789
1886
  const child = spawn(spawnSpec.command, spawnSpec.args, {
1790
1887
  stdio: 'inherit',
1791
1888
  ...(process.platform !== 'win32' ? { detached: true } : {}),
@@ -1840,9 +1937,21 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
1840
1937
  });
1841
1938
  }
1842
1939
  function logRecordFailureHint() {
1940
+ logger.info('');
1843
1941
  logger.info(`If ${pc.cyan('screenci test')} works but ${pc.cyan('screenci record')} fails, try ${pc.cyan('screenci test --mock-record')}.`);
1844
1942
  logger.info(`More info: ${pc.cyan(SCREENCI_MOCK_RECORD_DOCS_URL)}`);
1845
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
+ }
1846
1955
  // Only run if this file is being executed directly
1847
1956
  // Check if this module is the main module (handles symlinks properly)
1848
1957
  const currentFile = fileURLToPath(import.meta.url);
@@ -1853,10 +1962,7 @@ if (mainFile &&
1853
1962
  currentRealFile === mainFile ||
1854
1963
  currentFile === realpathSync(mainFile))) {
1855
1964
  main().catch((error) => {
1856
- if (isPartialUploadError(error)) {
1857
- process.exit(1);
1858
- }
1859
- logger.error('Error:', error.message);
1965
+ logCliError(error);
1860
1966
  process.exit(1);
1861
1967
  });
1862
1968
  }