screenci 0.0.90 → 0.0.91

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 (107) hide show
  1. package/dist/cli.d.ts +39 -1
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +357 -75
  4. package/dist/cli.js.map +1 -1
  5. package/dist/docs/manifest.d.ts +29 -29
  6. package/dist/docs/manifest.js +9 -9
  7. package/dist/docs/manifest.js.map +1 -1
  8. package/dist/docs/video-sources/camera-and-zooming.screenci.js +2 -9
  9. package/dist/docs/video-sources/camera-and-zooming.screenci.js.map +1 -1
  10. package/dist/docs/video-sources/editor.screenci.d.ts +2 -0
  11. package/dist/docs/video-sources/editor.screenci.d.ts.map +1 -0
  12. package/dist/docs/video-sources/{studio.screenci.js → editor.screenci.js} +18 -19
  13. package/dist/docs/video-sources/editor.screenci.js.map +1 -0
  14. package/dist/docs/video-sources/languages.screenci.js +12 -12
  15. package/dist/docs/video-sources/languages.screenci.js.map +1 -1
  16. package/dist/docs/video-sources/narration.screenci.js +11 -11
  17. package/dist/docs/video-sources/narration.screenci.js.map +1 -1
  18. package/dist/docs/video-sources/public-urls-and-embeds.screenci.js +2 -2
  19. package/dist/docs/video-sources/public-urls-and-embeds.screenci.js.map +1 -1
  20. package/dist/docs/videos.d.ts +2 -2
  21. package/dist/docs/videos.js +2 -2
  22. package/dist/index.d.ts +3 -4
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -4
  25. package/dist/index.js.map +1 -1
  26. package/dist/src/anonSession.d.ts +20 -6
  27. package/dist/src/anonSession.d.ts.map +1 -1
  28. package/dist/src/anonSession.js +60 -13
  29. package/dist/src/anonSession.js.map +1 -1
  30. package/dist/src/asset.d.ts +18 -15
  31. package/dist/src/asset.d.ts.map +1 -1
  32. package/dist/src/asset.js +18 -11
  33. package/dist/src/asset.js.map +1 -1
  34. package/dist/src/audio.d.ts +1 -1
  35. package/dist/src/audio.js +1 -1
  36. package/dist/src/builder.d.ts +25 -25
  37. package/dist/src/builder.d.ts.map +1 -1
  38. package/dist/src/builder.js +8 -8
  39. package/dist/src/builder.js.map +1 -1
  40. package/dist/src/clientOverlay.d.ts.map +1 -1
  41. package/dist/src/clientOverlay.js +3 -2
  42. package/dist/src/clientOverlay.js.map +1 -1
  43. package/dist/src/cue.d.ts +9 -9
  44. package/dist/src/cue.d.ts.map +1 -1
  45. package/dist/src/cue.js +6 -6
  46. package/dist/src/cue.js.map +1 -1
  47. package/dist/src/declare.d.ts +6 -6
  48. package/dist/src/declare.d.ts.map +1 -1
  49. package/dist/src/declare.js +9 -9
  50. package/dist/src/declare.js.map +1 -1
  51. package/dist/src/events.d.ts +8 -42
  52. package/dist/src/events.d.ts.map +1 -1
  53. package/dist/src/events.js +14 -24
  54. package/dist/src/events.js.map +1 -1
  55. package/dist/src/init.d.ts +2 -1
  56. package/dist/src/init.d.ts.map +1 -1
  57. package/dist/src/init.js +94 -143
  58. package/dist/src/init.js.map +1 -1
  59. package/dist/src/instrument.d.ts.map +1 -1
  60. package/dist/src/instrument.js +146 -0
  61. package/dist/src/instrument.js.map +1 -1
  62. package/dist/src/localize.d.ts +17 -11
  63. package/dist/src/localize.d.ts.map +1 -1
  64. package/dist/src/localize.js +1 -1
  65. package/dist/src/localize.js.map +1 -1
  66. package/dist/src/localizeRuntime.d.ts +8 -8
  67. package/dist/src/localizeRuntime.d.ts.map +1 -1
  68. package/dist/src/localizeRuntime.js +4 -4
  69. package/dist/src/localizeRuntime.js.map +1 -1
  70. package/dist/src/manualZoom.d.ts.map +1 -1
  71. package/dist/src/manualZoom.js +8 -3
  72. package/dist/src/manualZoom.js.map +1 -1
  73. package/dist/src/recordingData.d.ts +2 -2
  74. package/dist/src/runtimeContext.d.ts +0 -11
  75. package/dist/src/runtimeContext.d.ts.map +1 -1
  76. package/dist/src/runtimeContext.js +0 -7
  77. package/dist/src/runtimeContext.js.map +1 -1
  78. package/dist/src/screenshot.d.ts +7 -7
  79. package/dist/src/screenshot.d.ts.map +1 -1
  80. package/dist/src/studio.d.ts +41 -38
  81. package/dist/src/studio.d.ts.map +1 -1
  82. package/dist/src/studio.js +9 -9
  83. package/dist/src/studio.js.map +1 -1
  84. package/dist/src/types.d.ts +19 -10
  85. package/dist/src/types.d.ts.map +1 -1
  86. package/dist/src/video.d.ts +15 -15
  87. package/dist/src/video.d.ts.map +1 -1
  88. package/dist/src/video.js +10 -10
  89. package/dist/src/video.js.map +1 -1
  90. package/dist/src/voiceConfig.d.ts +26 -7
  91. package/dist/src/voiceConfig.d.ts.map +1 -1
  92. package/dist/src/voices.d.ts +22 -2
  93. package/dist/src/voices.d.ts.map +1 -1
  94. package/dist/src/voices.js +18 -0
  95. package/dist/src/voices.js.map +1 -1
  96. package/dist/tsconfig.tsbuildinfo +1 -1
  97. package/package.json +1 -1
  98. package/skills/screenci/SKILL.md +1 -1
  99. package/skills/screenci/references/init.md +3 -3
  100. package/skills/screenci/references/record.md +1 -1
  101. package/dist/docs/video-sources/studio.screenci.d.ts +0 -2
  102. package/dist/docs/video-sources/studio.screenci.d.ts.map +0 -1
  103. package/dist/docs/video-sources/studio.screenci.js.map +0 -1
  104. package/dist/src/recordingSize.d.ts +0 -32
  105. package/dist/src/recordingSize.d.ts.map +0 -1
  106. package/dist/src/recordingSize.js +0 -54
  107. package/dist/src/recordingSize.js.map +0 -1
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { createReadStream } from 'fs';
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, } from 'fs';
4
4
  import { createHash, randomUUID } from 'crypto';
5
5
  import { createRequire } from 'module';
6
- import { appendFile, readdir, readFile, stat, writeFile } from 'fs/promises';
6
+ import { appendFile, mkdir, readdir, readFile, rm, stat, writeFile, } from 'fs/promises';
7
7
  import { delimiter, dirname, isAbsolute, relative as pathRelative, resolve, } from 'path';
8
8
  import { fileURLToPath, pathToFileURL } from 'url';
9
9
  import { Command, CommanderError } from 'commander';
@@ -17,7 +17,7 @@ import { findDuplicateTitles, formatDuplicateTitlesMessage, } from './src/titleV
17
17
  import { getCliLinkSessionApiUrl, getDevBackendUrl, getDevFrontendUrl, getScreenCISecretsUrl, persistScreenCISecret, } from './src/linkSession.js';
18
18
  import { OVERLAY_CACHE_DIR_NAME } from './src/htmlRasterizer.js';
19
19
  import { maybeExtractVoiceSampleAudio } from './src/voiceSampleAudio.js';
20
- import { ANON_SESSION_FILE, anonCredential, checkAnonSessionStatus, deleteAnonSessionFile, evaluateAnonRecordingGate, formatAnonTermsNotice, getOrCreateAnonToken, secretCredential, SCREENCI_TERMS_URL, } from './src/anonSession.js';
20
+ import { ANON_MAX_VIDEOS_PER_RECORDING, ANON_SESSION_FILE, ANON_TOKEN_HEADER, anonCredential, checkAnonSessionStatus, deleteAnonSessionFile, evaluateAnonRecordingGate, formatAnonPostRecordNotice, formatAnonTermsNotice, getOrCreateAnonToken, readAnonSessionRecordUrl, saveAnonSessionRecordUrl, secretCredential, } from './src/anonSession.js';
21
21
  // Re-export the environment-aware URL helpers so existing importers (and tests)
22
22
  // can keep importing them from the CLI entrypoint.
23
23
  export { getCliLinkSessionApiUrl, getDevBackendUrl, getDevFrontendUrl };
@@ -31,6 +31,8 @@ const SCREENCI_RECORD_DOCS_URL = 'https://screenci.com/docs/reference/cli/#scree
31
31
  // Records the recordId of the most recent `screenci record` upload so
32
32
  // `screenci info` can report exactly the run that was just made.
33
33
  const SCREENCI_LAST_RECORD_FILE = 'last-record.json';
34
+ const SCREENCI_RECORD_LOCK_FILE = '.record.lock';
35
+ const SCREENCI_RECORD_LOCK_MAX_AGE_MS = 6 * 60 * 60 * 1000;
34
36
  const require = createRequire(import.meta.url);
35
37
  /**
36
38
  * Reports whether the current session can complete an interactive browser
@@ -228,9 +230,9 @@ const UPLOAD_ASSET_LABEL = {
228
230
  cursor: 'Cursor',
229
231
  };
230
232
  export function formatStudioUrl(appUrl, projectId, videoId) {
231
- // The video hub resolves `?studio` to the right language page and scrolls it
232
- // to Studio, so we never need to guess the language in the printed link.
233
- return `${appUrl}/project/${projectId}/video/${videoId}?studio`;
233
+ // The video hub resolves `?editor` to the right language page and scrolls it
234
+ // to Editor, so we never need to guess the language in the printed link.
235
+ return `${appUrl}/project/${projectId}/video/${videoId}?editor`;
234
236
  }
235
237
  class UploadAssetError extends Error {
236
238
  constructor(message) {
@@ -350,12 +352,37 @@ async function loadUploadCandidate(screenciDir, entry, verbose) {
350
352
  return {
351
353
  entry,
352
354
  videoName,
355
+ displayVideoName: videoName,
353
356
  data,
354
357
  preparedUploadAssets,
355
358
  };
356
359
  }
360
+ function disambiguateUploadCandidateDisplayNames(candidates) {
361
+ const counts = new Map();
362
+ for (const candidate of candidates) {
363
+ counts.set(candidate.videoName, (counts.get(candidate.videoName) ?? 0) + 1);
364
+ }
365
+ return candidates.map((candidate) => {
366
+ if ((counts.get(candidate.videoName) ?? 0) <= 1)
367
+ return candidate;
368
+ const languages = candidate.data.metadata?.languages;
369
+ if (Array.isArray(languages) && languages.length === 1) {
370
+ const [language] = languages;
371
+ if (typeof language === 'string' && language.length > 0) {
372
+ return {
373
+ ...candidate,
374
+ displayVideoName: `${candidate.videoName} [${language}]`,
375
+ };
376
+ }
377
+ }
378
+ if (candidate.entry !== candidate.videoName) {
379
+ return { ...candidate, displayVideoName: candidate.entry };
380
+ }
381
+ return candidate;
382
+ });
383
+ }
357
384
  async function uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, credential, verbose, uploadAbort, progressReporter, progressIndex, recordId, expectedScreenshotCount) {
358
- const { entry, videoName, data: rawData, preparedUploadAssets } = candidate;
385
+ const { entry, videoName, displayVideoName, data: rawData, preparedUploadAssets, } = candidate;
359
386
  let projectId = null;
360
387
  let videoId = null;
361
388
  let plan = null;
@@ -376,8 +403,8 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
376
403
  projectId: null,
377
404
  videoId: null,
378
405
  hadFailure: true,
379
- videoName,
380
- failureMessage: `Missing ${recordingFileName} for "${videoName}"`,
406
+ videoName: displayVideoName,
407
+ failureMessage: `Missing ${recordingFileName} for "${displayVideoName}"`,
381
408
  recordId,
382
409
  };
383
410
  }
@@ -392,8 +419,8 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
392
419
  projectId: null,
393
420
  videoId: null,
394
421
  hadFailure: true,
395
- videoName,
396
- failureMessage: formatUnresolvedAssetMessage(videoName, unresolved),
422
+ videoName: displayVideoName,
423
+ failureMessage: formatUnresolvedAssetMessage(displayVideoName, unresolved),
397
424
  recordId,
398
425
  };
399
426
  }
@@ -437,7 +464,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
437
464
  videoId: null,
438
465
  hadFailure: true,
439
466
  elevenLabsKeyMissing: true,
440
- videoName,
467
+ videoName: displayVideoName,
441
468
  recordId,
442
469
  };
443
470
  }
@@ -445,8 +472,8 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
445
472
  projectId: null,
446
473
  videoId: null,
447
474
  hadFailure: true,
448
- videoName,
449
- failureMessage: formatUploadStartFailureMessage(videoName, startResponse.status, text, credential.value),
475
+ videoName: displayVideoName,
476
+ failureMessage: formatUploadStartFailureMessage(displayVideoName, startResponse.status, text, credential.value),
450
477
  recordId,
451
478
  };
452
479
  }
@@ -461,7 +488,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
461
488
  // the render for this video fails with the dependency error until fixed.
462
489
  if (startBody.dependencyErrors && startBody.dependencyErrors.length > 0) {
463
490
  for (const depError of startBody.dependencyErrors) {
464
- logger.error(`Render dependency error in "${videoName}": ${depError.detail}. This render will fail until it is fixed.`);
491
+ logger.error(`Render dependency error in "${displayVideoName}": ${depError.detail}. This render will fail until it is fixed.`);
465
492
  }
466
493
  }
467
494
  // A missing ElevenLabs key is a hard failure returned as an error response
@@ -508,8 +535,8 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
508
535
  projectId,
509
536
  videoId,
510
537
  hadFailure: true,
511
- videoName,
512
- failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${extractBackendError(text)}${hint401(recordingResponse.status, credential.value)}`,
538
+ videoName: displayVideoName,
539
+ failureMessage: `Failed to upload recording for "${displayVideoName}": ${recordingResponse.status} ${extractBackendError(text)}${hint401(recordingResponse.status, credential.value)}`,
513
540
  recordId,
514
541
  ...(plan !== null && { plan }),
515
542
  };
@@ -520,7 +547,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
520
547
  projectId,
521
548
  videoId,
522
549
  hadFailure: false,
523
- videoName,
550
+ videoName: displayVideoName,
524
551
  recordId,
525
552
  ...(studio !== undefined && { studio }),
526
553
  ...(plan !== null && { plan }),
@@ -541,7 +568,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
541
568
  projectId,
542
569
  videoId,
543
570
  hadFailure: true,
544
- videoName,
571
+ videoName: displayVideoName,
545
572
  failureMessage: err instanceof Error ? err.message : String(err),
546
573
  recordId,
547
574
  ...(plan !== null && { plan }),
@@ -552,8 +579,8 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
552
579
  projectId,
553
580
  videoId,
554
581
  hadFailure: true,
555
- videoName,
556
- failureMessage: `Network error uploading "${videoName}": ${err instanceof Error ? err.message : String(err)}`,
582
+ videoName: displayVideoName,
583
+ failureMessage: `Network error uploading "${displayVideoName}": ${err instanceof Error ? err.message : String(err)}`,
557
584
  recordId,
558
585
  ...(plan !== null && { plan }),
559
586
  };
@@ -1332,6 +1359,32 @@ export function formatFailedVideoMessage(videoName, message) {
1332
1359
  }
1333
1360
  return `${videoName}: ${message}`;
1334
1361
  }
1362
+ function quoteFailedVideoName(videoName) {
1363
+ return `'${videoName.replaceAll("'", "\\'")}'`;
1364
+ }
1365
+ export function formatFailedVideoNamesSummary(videoNames) {
1366
+ if (videoNames.length === 0)
1367
+ return 'unknown';
1368
+ return videoNames
1369
+ .map((videoName) => quoteFailedVideoName(videoName))
1370
+ .join(', ');
1371
+ }
1372
+ export function collapseFailedVideoWarnings(failures) {
1373
+ const byMessage = new Map();
1374
+ for (const failure of failures) {
1375
+ const names = byMessage.get(failure.message);
1376
+ if (names)
1377
+ names.push(failure.videoName);
1378
+ else
1379
+ byMessage.set(failure.message, [failure.videoName]);
1380
+ }
1381
+ return [...byMessage.entries()].map(([message, videoNames]) => {
1382
+ if (videoNames.length === 1) {
1383
+ return formatFailedVideoMessage(videoNames[0] ?? 'unknown', message);
1384
+ }
1385
+ return message;
1386
+ });
1387
+ }
1335
1388
  export function printUploadStartFailureMessage(videoName, status, responseText, secret) {
1336
1389
  const message = formatUploadStartFailureMessage(videoName, status, responseText, secret);
1337
1390
  if (responseText.trim().length > 0) {
@@ -1514,7 +1567,7 @@ async function uploadAssets(assets, apiUrl, credential, recordingId, signal, thr
1514
1567
  }
1515
1568
  }
1516
1569
  }
1517
- export async function uploadRecordings(screenciDir, projectName, apiUrl, credential, specificEntry, verbose = false) {
1570
+ export async function uploadRecordings(screenciDir, projectName, apiUrl, credential, specificEntry, verbose = false, allowedVideoNames) {
1518
1571
  const uploadAbort = createUploadAbortController('upload');
1519
1572
  const recordId = randomUUID();
1520
1573
  let entries;
@@ -1527,6 +1580,7 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, credent
1527
1580
  projectId: null,
1528
1581
  recordId: null,
1529
1582
  hadFailures: false,
1583
+ uploadedVideoNames: [],
1530
1584
  failedVideoNames: [],
1531
1585
  failedVideoMessages: [],
1532
1586
  studioNotices: [],
@@ -1540,33 +1594,66 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, credent
1540
1594
  }
1541
1595
  let firstProjectId = null;
1542
1596
  try {
1543
- const candidates = (await Promise.all(entries.map(async (entry) => {
1597
+ const candidates = disambiguateUploadCandidateDisplayNames((await Promise.all(entries.map(async (entry) => {
1544
1598
  uploadAbort.throwIfAborted();
1545
1599
  return await loadUploadCandidate(screenciDir, entry, verbose);
1546
- }))).filter((candidate) => candidate !== null);
1547
- if (candidates.length === 0) {
1600
+ }))).filter((candidate) => candidate !== null));
1601
+ const requestedVideoNames = allowedVideoNames !== undefined ? new Set(allowedVideoNames) : null;
1602
+ const filteredCandidates = requestedVideoNames === null
1603
+ ? candidates
1604
+ : candidates.filter((candidate) => requestedVideoNames.has(candidate.videoName));
1605
+ if (filteredCandidates.length === 0) {
1606
+ const missingRequestedVideoNames = requestedVideoNames === null
1607
+ ? []
1608
+ : [...requestedVideoNames].filter((videoName) => !candidates.some((candidate) => candidate.videoName === videoName));
1548
1609
  return {
1549
1610
  projectId: null,
1550
1611
  recordId: null,
1551
- hadFailures: false,
1552
- failedVideoNames: [],
1553
- failedVideoMessages: [],
1612
+ hadFailures: missingRequestedVideoNames.length > 0,
1613
+ uploadedVideoNames: [],
1614
+ failedVideoNames: missingRequestedVideoNames,
1615
+ failedVideoMessages: missingRequestedVideoNames.map((videoName) => ({
1616
+ videoName,
1617
+ message: `No recorded output found for "${videoName}"`,
1618
+ })),
1554
1619
  studioNotices: [],
1555
1620
  elevenLabsKeyMissingVideos: [],
1556
1621
  notices: [],
1557
1622
  plan: null,
1558
1623
  };
1559
1624
  }
1560
- const progressReporter = createUploadProgressReporter(candidates.map((candidate) => candidate.videoName), verbose);
1625
+ if (credential.header === ANON_TOKEN_HEADER &&
1626
+ filteredCandidates.length > ANON_MAX_VIDEOS_PER_RECORDING) {
1627
+ const failureMessage = `Anonymous trials are capped at ${ANON_MAX_VIDEOS_PER_RECORDING} videos/screenshots per recording. Split this into smaller runs or sign up to record more in one run.`;
1628
+ return {
1629
+ projectId: null,
1630
+ recordId: null,
1631
+ hadFailures: true,
1632
+ uploadedVideoNames: [],
1633
+ failedVideoNames: filteredCandidates.map((candidate) => candidate.displayVideoName),
1634
+ failedVideoMessages: filteredCandidates.map((candidate) => ({
1635
+ videoName: candidate.displayVideoName,
1636
+ message: failureMessage,
1637
+ })),
1638
+ studioNotices: [],
1639
+ elevenLabsKeyMissingVideos: [],
1640
+ notices: [],
1641
+ plan: null,
1642
+ };
1643
+ }
1644
+ const progressReporter = createUploadProgressReporter(filteredCandidates.map((candidate) => candidate.displayVideoName), verbose);
1561
1645
  // Screenshots from this run render together on one machine; the backend
1562
1646
  // waits for all of them to land before dispatching the batch, so it needs
1563
1647
  // to know how many to expect.
1564
- const screenshotCount = candidates.filter((candidate) => candidate.data.output === 'screenshot').length;
1565
- const results = await Promise.all(candidates.map(async (candidate, index) => await uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, credential, verbose, uploadAbort, progressReporter, index, recordId, screenshotCount)));
1648
+ const screenshotCount = filteredCandidates.filter((candidate) => candidate.data.output === 'screenshot').length;
1649
+ const results = await Promise.all(filteredCandidates.map(async (candidate, index) => await uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, credential, verbose, uploadAbort, progressReporter, index, recordId, screenshotCount)));
1566
1650
  firstProjectId =
1567
1651
  results.find((result) => result.projectId !== null)?.projectId ?? null;
1568
1652
  const resolvedPlan = results.find((result) => result.plan !== undefined)?.plan ?? null;
1569
1653
  const hadFailures = results.some((result) => result.hadFailure);
1654
+ const uploadedVideoNames = results
1655
+ .filter((result) => !result.hadFailure)
1656
+ .map((result) => result.videoName);
1570
1657
  const failedVideoNames = results
1571
1658
  .filter((result) => result.hadFailure)
1572
1659
  .map((result) => result.videoName);
@@ -1588,6 +1675,7 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, credent
1588
1675
  projectId: firstProjectId,
1589
1676
  recordId,
1590
1677
  hadFailures,
1678
+ uploadedVideoNames,
1591
1679
  failedVideoNames,
1592
1680
  failedVideoMessages,
1593
1681
  studioNotices,
@@ -1616,6 +1704,35 @@ async function writeGitHubProjectOutput(projectUrl) {
1616
1704
  return;
1617
1705
  await appendFile(githubOutput, `screenci_project_url=${projectUrl}\n`);
1618
1706
  }
1707
+ /**
1708
+ * The builder titles each per-language Playwright test `${videoName} [${lang}]`
1709
+ * (src/builder.ts) so every language pass has a unique test title, while the
1710
+ * shared grouping key it writes to `metadata.videoName` carries NO language
1711
+ * suffix. `screenci record` discovers the videos to expect by their test titles,
1712
+ * but the uploader matches those against each recording's `metadata.videoName`.
1713
+ * Strip the trailing ` [<lang>]` so the requested name matches the recorded one;
1714
+ * otherwise a language-decorated title never matches and every upload reports
1715
+ * "No recorded output found". Only a language-code-shaped bracket is stripped, so
1716
+ * an unrelated trailing bracket in a video name is left intact.
1717
+ */
1718
+ export function stripTestTitleLanguageSuffix(title) {
1719
+ // Language codes are lowercase (ISO 639: en, es, zh, ...), with an optional
1720
+ // region subtag (pt-BR). Requiring lowercase avoids stripping unrelated
1721
+ // capitalized brackets like ` [New]`.
1722
+ return title.replace(/ \[[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})?\]$/, '');
1723
+ }
1724
+ async function collectRequestedRecordVideoNames(configPath, additionalArgs, languages) {
1725
+ const envForDiscovery = {
1726
+ ...process.env,
1727
+ SCREENCI_CONFIG_DIR: dirname(configPath),
1728
+ SCREENCI_RECORDING: 'true',
1729
+ ...(languages ? { [SCREENCI_LANGUAGES_ENV]: languages } : {}),
1730
+ };
1731
+ const titles = await collectDiscoveredTestTitles(configPath, additionalArgs, envForDiscovery);
1732
+ // Requested names are matched against `metadata.videoName` (no language
1733
+ // suffix), so recover the videoName from each per-language test title.
1734
+ return [...new Set(titles.map(stripTestTitleLanguageSuffix))];
1735
+ }
1619
1736
  async function loadScreenCIConfigAndEnv(configPath) {
1620
1737
  const resolvedConfigPath = resolveScreenCIConfigPathOrExit(configPath);
1621
1738
  let screenciConfig;
@@ -1938,6 +2055,134 @@ async function triggerRemoteRun(configPath, grep, languages) {
1938
2055
  ? `Triggered the remote recording workflow for "${screenciConfig.projectName}" (${filters.join(', ')}).`
1939
2056
  : `Triggered the remote recording workflow for "${screenciConfig.projectName}".`);
1940
2057
  }
2058
+ function getRecordRunLockPath(screenciDir) {
2059
+ return resolve(screenciDir, SCREENCI_RECORD_LOCK_FILE);
2060
+ }
2061
+ function defaultIsPidAlive(pid) {
2062
+ if (!Number.isInteger(pid) || pid <= 0)
2063
+ return false;
2064
+ try {
2065
+ process.kill(pid, 0);
2066
+ return true;
2067
+ }
2068
+ catch (error) {
2069
+ if (error instanceof Error && 'code' in error && error.code === 'ESRCH') {
2070
+ return false;
2071
+ }
2072
+ return true;
2073
+ }
2074
+ }
2075
+ function parseRecordRunLock(raw) {
2076
+ try {
2077
+ const parsed = JSON.parse(raw);
2078
+ return typeof parsed.pid === 'number' &&
2079
+ Number.isInteger(parsed.pid) &&
2080
+ parsed.pid > 0 &&
2081
+ typeof parsed.startedAt === 'string' &&
2082
+ parsed.startedAt.length > 0 &&
2083
+ typeof parsed.projectName === 'string' &&
2084
+ parsed.projectName.length > 0
2085
+ ? {
2086
+ pid: parsed.pid,
2087
+ startedAt: parsed.startedAt,
2088
+ projectName: parsed.projectName,
2089
+ }
2090
+ : null;
2091
+ }
2092
+ catch {
2093
+ return null;
2094
+ }
2095
+ }
2096
+ function isRecordRunLockStale(lock, now, isPidAlive, maxAgeMs) {
2097
+ const startedAtMs = Date.parse(lock.startedAt);
2098
+ if (Number.isNaN(startedAtMs))
2099
+ return true;
2100
+ if (now.getTime() - startedAtMs > maxAgeMs)
2101
+ return true;
2102
+ return !isPidAlive(lock.pid);
2103
+ }
2104
+ function formatRecordRunLockError(lock) {
2105
+ return `Another 'screenci record' is in progress (pid ${lock.pid}, started ${lock.startedAt}, project "${lock.projectName}"). Wait for it or remove .screenci/.record.lock.`;
2106
+ }
2107
+ export async function acquireRecordRunLock(screenciDir, projectName, deps = {}) {
2108
+ const resolvedDeps = {
2109
+ fs: deps.fs ?? { mkdir, readFile, writeFile, rm },
2110
+ clock: deps.clock ?? (() => new Date()),
2111
+ isPidAlive: deps.isPidAlive ?? defaultIsPidAlive,
2112
+ pid: deps.pid ?? process.pid,
2113
+ addSignalListener: deps.addSignalListener ?? process.on.bind(process),
2114
+ removeSignalListener: deps.removeSignalListener ?? process.off.bind(process),
2115
+ removeLockSync: deps.removeLockSync ??
2116
+ ((lockPath) => {
2117
+ rmSync(lockPath, { force: true });
2118
+ }),
2119
+ maxAgeMs: deps.maxAgeMs ?? SCREENCI_RECORD_LOCK_MAX_AGE_MS,
2120
+ };
2121
+ const lockPath = getRecordRunLockPath(screenciDir);
2122
+ const lock = {
2123
+ pid: resolvedDeps.pid,
2124
+ startedAt: resolvedDeps.clock().toISOString(),
2125
+ projectName,
2126
+ };
2127
+ await resolvedDeps.fs.mkdir(screenciDir, { recursive: true });
2128
+ for (;;) {
2129
+ try {
2130
+ await resolvedDeps.fs.writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`, { flag: 'wx' });
2131
+ break;
2132
+ }
2133
+ catch (error) {
2134
+ if (!(error instanceof Error) ||
2135
+ !('code' in error) ||
2136
+ error.code !== 'EEXIST') {
2137
+ throw error;
2138
+ }
2139
+ let existingLock = null;
2140
+ try {
2141
+ existingLock = parseRecordRunLock(await resolvedDeps.fs.readFile(lockPath, 'utf-8'));
2142
+ }
2143
+ catch {
2144
+ existingLock = null;
2145
+ }
2146
+ if (existingLock !== null &&
2147
+ !isRecordRunLockStale(existingLock, resolvedDeps.clock(), resolvedDeps.isPidAlive, resolvedDeps.maxAgeMs)) {
2148
+ throw new Error(formatRecordRunLockError(existingLock));
2149
+ }
2150
+ await resolvedDeps.fs.rm(lockPath, { force: true });
2151
+ }
2152
+ }
2153
+ let released = false;
2154
+ const removeLockSync = () => {
2155
+ if (released)
2156
+ return;
2157
+ released = true;
2158
+ resolvedDeps.removeSignalListener('SIGINT', handleSigint);
2159
+ resolvedDeps.removeSignalListener('SIGTERM', handleSigterm);
2160
+ try {
2161
+ resolvedDeps.removeLockSync(lockPath);
2162
+ }
2163
+ catch {
2164
+ // best-effort during signal shutdown
2165
+ }
2166
+ };
2167
+ const release = async () => {
2168
+ if (released)
2169
+ return;
2170
+ released = true;
2171
+ resolvedDeps.removeSignalListener('SIGINT', handleSigint);
2172
+ resolvedDeps.removeSignalListener('SIGTERM', handleSigterm);
2173
+ try {
2174
+ await resolvedDeps.fs.rm(lockPath, { force: true });
2175
+ }
2176
+ catch {
2177
+ // best-effort cleanup
2178
+ }
2179
+ };
2180
+ const handleSigint = () => removeLockSync();
2181
+ const handleSigterm = () => removeLockSync();
2182
+ resolvedDeps.addSignalListener('SIGINT', handleSigint);
2183
+ resolvedDeps.addSignalListener('SIGTERM', handleSigterm);
2184
+ return { release };
2185
+ }
1941
2186
  function getLastRecordFilePath(screenciDir) {
1942
2187
  return resolve(screenciDir, SCREENCI_LAST_RECORD_FILE);
1943
2188
  }
@@ -2043,14 +2288,18 @@ export async function ensureAnonRecordingAllowedOrExit(screenciDir, apiUrl, appU
2043
2288
  // upload. Skip a `claimed` session: that user already accepted the
2044
2289
  // versioned Terms when they signed up (the upload path self-upgrades).
2045
2290
  if (status.status !== 'claimed') {
2046
- logger.info(pc.cyan(formatAnonTermsNotice()));
2291
+ logger.info(formatAnonTermsNotice());
2047
2292
  }
2048
2293
  return;
2049
2294
  }
2050
2295
  const intro = gate.reason === 'expired'
2051
2296
  ? 'Your free ScreenCI trial has expired.'
2052
- : "You've already used your one free ScreenCI trial recording.";
2297
+ : "You've used all your free ScreenCI trial recordings.";
2298
+ const previousRecordUrl = gate.reason === 'used' ? await readAnonSessionRecordUrl(screenciDir) : null;
2053
2299
  logger.error(`${intro}\n` +
2300
+ (previousRecordUrl
2301
+ ? `Previous recording: ${pc.cyan(previousRecordUrl)}\n`
2302
+ : '') +
2054
2303
  `Sign up to keep recording (no watermark, no limits): ${pc.cyan(appUrl)}\n` +
2055
2304
  'After signing up, re-run this command in the same folder and it links automatically.');
2056
2305
  process.exit(1);
@@ -2060,7 +2309,7 @@ export async function ensureAnonRecordingAllowedOrExit(screenciDir, apiUrl, appU
2060
2309
  // re-sends the existing recordings without re-running Playwright). A non-null
2061
2310
  // `playwrightFailure` means the preceding record run had failures, which tunes
2062
2311
  // the messaging and the upload policy; `retry` always passes null.
2063
- async function uploadRecordedVideosForConfig(configPath, playwrightFailure, verbose) {
2312
+ async function uploadRecordedVideosForConfig(configPath, playwrightFailure, verbose, requestedVideoNames) {
2064
2313
  // After recording, upload results to API if configured. `run` already
2065
2314
  // resolved the config (or exited), so this best-effort lookup only acts
2066
2315
  // when a flat config is present in/under the current directory.
@@ -2098,6 +2347,7 @@ async function uploadRecordedVideosForConfig(configPath, playwrightFailure, verb
2098
2347
  projectId: null,
2099
2348
  recordId: null,
2100
2349
  hadFailures: false,
2350
+ uploadedVideoNames: [],
2101
2351
  failedVideoNames: [],
2102
2352
  failedVideoMessages: [],
2103
2353
  studioNotices: [],
@@ -2106,7 +2356,7 @@ async function uploadRecordedVideosForConfig(configPath, playwrightFailure, verb
2106
2356
  plan: null,
2107
2357
  };
2108
2358
  try {
2109
- uploadResult = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, credential, undefined, verbose);
2359
+ uploadResult = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, credential, undefined, verbose, requestedVideoNames);
2110
2360
  }
2111
2361
  catch (err) {
2112
2362
  if (isUploadCancelledError(err)) {
@@ -2114,9 +2364,12 @@ async function uploadRecordedVideosForConfig(configPath, playwrightFailure, verb
2114
2364
  }
2115
2365
  throw err;
2116
2366
  }
2117
- const { projectId, recordId, hadFailures, failedVideoNames, failedVideoMessages, studioNotices, elevenLabsKeyMissingVideos, notices, plan, } = uploadResult;
2367
+ const { projectId, recordId, hadFailures, uploadedVideoNames, failedVideoNames, failedVideoMessages, studioNotices, elevenLabsKeyMissingVideos, notices, plan, } = uploadResult;
2368
+ const requestedUploadSucceeded = !hadFailures &&
2369
+ (requestedVideoNames === undefined ||
2370
+ requestedVideoNames.every((videoName) => uploadedVideoNames.includes(videoName)));
2118
2371
  // Remember this run so `screenci info` can report exactly it.
2119
- if (recordId !== null) {
2372
+ if (recordId !== null && requestedUploadSucceeded) {
2120
2373
  await saveLastRecordId(screenciDir, recordId);
2121
2374
  }
2122
2375
  // Emit upload-failure warnings (stderr) before the results block.
@@ -2126,13 +2379,15 @@ async function uploadRecordedVideosForConfig(configPath, playwrightFailure, verb
2126
2379
  // it from its URL. Reporting failures first keeps the URL directly
2127
2380
  // under its message.
2128
2381
  if (hadFailures) {
2129
- for (const failedVideo of failedVideoMessages) {
2130
- logger.warn(formatFailedVideoMessage(failedVideo.videoName, failedVideo.message));
2382
+ for (const warning of collapseFailedVideoWarnings(failedVideoMessages)) {
2383
+ logger.warn(warning);
2131
2384
  }
2132
- logger.warn(`Not all recordings succeeded to upload. Failed videos: ${failedVideoNames.join(', ') || 'unknown'}. Some videos may be missing from the project.`);
2385
+ logger.warn(`Not all recordings succeeded to upload. Failed videos: ${formatFailedVideoNamesSummary(failedVideoNames)}. Some videos may be missing from the project.`);
2133
2386
  }
2134
- if (recordId !== null && projectId !== null) {
2387
+ let resultUrl = null;
2388
+ if (requestedUploadSucceeded && recordId !== null && projectId !== null) {
2135
2389
  const recordUrl = `${appUrl}/record/${recordId}`;
2390
+ resultUrl = recordUrl;
2136
2391
  await writeGitHubProjectOutput(recordUrl);
2137
2392
  logger.info('');
2138
2393
  logger.info(playwrightFailure !== null
@@ -2140,8 +2395,9 @@ async function uploadRecordedVideosForConfig(configPath, playwrightFailure, verb
2140
2395
  : 'Recording finished, rendering in progress. Results available at:');
2141
2396
  logger.info(pc.cyan(recordUrl));
2142
2397
  }
2143
- else if (projectId !== null) {
2398
+ else if (requestedUploadSucceeded && projectId !== null) {
2144
2399
  const projectUrl = `${appUrl}/project/${projectId}`;
2400
+ resultUrl = projectUrl;
2145
2401
  await writeGitHubProjectOutput(projectUrl);
2146
2402
  logger.info('');
2147
2403
  logger.info(playwrightFailure !== null
@@ -2149,8 +2405,25 @@ async function uploadRecordedVideosForConfig(configPath, playwrightFailure, verb
2149
2405
  : 'Recording finished, rendering in progress. Results available at:');
2150
2406
  logger.info(pc.cyan(projectUrl));
2151
2407
  }
2152
- if (usedAnonCredential && (recordId !== null || projectId !== null)) {
2153
- logger.info(`Recorded without an account. Open the link above to view it, and sign up to keep it (by continuing you agree to the Terms: ${SCREENCI_TERMS_URL}).`);
2408
+ if (usedAnonCredential && resultUrl !== null) {
2409
+ try {
2410
+ await saveAnonSessionRecordUrl(screenciDir, credential.value, resultUrl);
2411
+ }
2412
+ catch (err) {
2413
+ logger.warn(`Failed to remember anonymous recording URL: ${err instanceof Error ? err.message : String(err)}`);
2414
+ }
2415
+ // Report how many trial recordings remain after this run. Best-effort:
2416
+ // checkAnonSessionStatus never throws (it falls back on failure), so a
2417
+ // transient outage just shows the optimistic remaining count.
2418
+ const postStatus = await checkAnonSessionStatus(credential.value, {
2419
+ backendUrl: apiUrl,
2420
+ });
2421
+ if (postStatus.status === 'pending') {
2422
+ logger.info(formatAnonPostRecordNotice(postStatus.remaining));
2423
+ }
2424
+ else {
2425
+ logger.info(`Recorded without an account. Sign up to keep it.`);
2426
+ }
2154
2427
  }
2155
2428
  if (notices.length > 0) {
2156
2429
  logger.info('');
@@ -2158,14 +2431,9 @@ async function uploadRecordedVideosForConfig(configPath, playwrightFailure, verb
2158
2431
  logger.notice(notice);
2159
2432
  }
2160
2433
  }
2161
- if (projectId !== null && plan !== 'business') {
2434
+ if (projectId !== null && plan === 'starter') {
2162
2435
  logger.info('');
2163
- if (plan === 'free') {
2164
- logger.info('You are on the free tier, so this render includes a ScreenCI watermark. Upgrade to remove it and get more renders, more active videos, and expressive narration:');
2165
- }
2166
- else {
2167
- logger.info('Upgrade for more renders, more active videos, and expressive narration:');
2168
- }
2436
+ logger.info('Upgrade for more renders, more active videos, and expressive narration:');
2169
2437
  logger.info(pc.cyan(`${appUrl}/select-plan`));
2170
2438
  }
2171
2439
  for (const notice of studioNotices) {
@@ -2229,35 +2497,49 @@ export async function main() {
2229
2497
  await triggerRemoteRun(parsed.configPath, extractGrep(parsed.otherArgs), parsed.languages);
2230
2498
  return;
2231
2499
  }
2232
- let playwrightFailure = null;
2233
- // UPLOAD_EXISTING re-sends the recordings already on disk under `.screenci`
2234
- // without re-running Playwright (resend the last local run when only the
2235
- // upload failed). We skip the recording run and fall straight through to
2236
- // the upload below, treating the on-disk recordings as the complete set.
2237
- const uploadExisting = isUploadExistingEnabled();
2238
- if (!uploadExisting) {
2239
- try {
2240
- await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose, false, parsed.languages);
2241
- }
2242
- catch (error) {
2243
- if (!(error instanceof Error))
2244
- throw error;
2245
- if (error.message.startsWith('Playwright exited with code ')) {
2246
- playwrightFailure = new RecordFailureHintError(error);
2500
+ validateArgs(parsed.otherArgs);
2501
+ const resolvedConfigPath = resolveScreenCIConfigPathOrExit(parsed.configPath);
2502
+ await loadEnvFileFromConfigSource(resolvedConfigPath, false);
2503
+ const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
2504
+ const screenciDir = resolve(dirname(resolvedConfigPath), '.screenci');
2505
+ const requestedVideoNames = parsed.otherArgs.length > 0 || parsed.languages !== undefined
2506
+ ? await collectRequestedRecordVideoNames(resolvedConfigPath, parsed.otherArgs, parsed.languages)
2507
+ : undefined;
2508
+ const recordRunLock = await acquireRecordRunLock(screenciDir, screenciConfig.projectName);
2509
+ try {
2510
+ let playwrightFailure = null;
2511
+ // UPLOAD_EXISTING re-sends the recordings already on disk under `.screenci`
2512
+ // without re-running Playwright (resend the last local run when only the
2513
+ // upload failed). We skip the recording run and fall straight through to
2514
+ // the upload below, treating the on-disk recordings as the complete set.
2515
+ const uploadExisting = isUploadExistingEnabled();
2516
+ if (!uploadExisting) {
2517
+ try {
2518
+ await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose, false, parsed.languages);
2247
2519
  }
2248
- else {
2249
- throw new RecordFailureHintError(error);
2520
+ catch (error) {
2521
+ if (!(error instanceof Error))
2522
+ throw error;
2523
+ if (error.message.startsWith('Playwright exited with code ')) {
2524
+ playwrightFailure = new RecordFailureHintError(error);
2525
+ }
2526
+ else {
2527
+ throw new RecordFailureHintError(error);
2528
+ }
2250
2529
  }
2251
2530
  }
2531
+ else {
2532
+ logger.info('UPLOAD_EXISTING set: skipping Playwright recording and re-uploading existing .screenci recordings.');
2533
+ }
2534
+ if (process.env.SCREENCI_RECORDING === 'true')
2535
+ return;
2536
+ await uploadRecordedVideosForConfig(parsed.configPath, playwrightFailure, parsed.verbose, requestedVideoNames);
2537
+ if (playwrightFailure !== null) {
2538
+ throw playwrightFailure;
2539
+ }
2252
2540
  }
2253
- else {
2254
- logger.info('UPLOAD_EXISTING set: skipping Playwright recording and re-uploading existing .screenci recordings.');
2255
- }
2256
- if (process.env.SCREENCI_RECORDING === 'true')
2257
- return;
2258
- await uploadRecordedVideosForConfig(parsed.configPath, playwrightFailure, parsed.verbose);
2259
- if (playwrightFailure !== null) {
2260
- throw playwrightFailure;
2541
+ finally {
2542
+ await recordRunLock.release();
2261
2543
  }
2262
2544
  });
2263
2545
  program