screenci 0.0.65 → 0.0.67

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.
package/dist/cli.d.ts CHANGED
@@ -55,6 +55,8 @@ export type StudioUploadNotice = {
55
55
  */
56
56
  export declare function formatStudioChangeSummary(changes: StudioAppliedChange[]): string;
57
57
  export declare function formatStudioUrl(appUrl: string, projectId: string, videoId: string): string;
58
+ type OrgPlan = 'free' | 'starter' | 'business';
59
+ export declare function withUploadRetry<T>(fn: () => Promise<T>, signal: AbortSignal | undefined, maxAttempts?: number): Promise<T>;
58
60
  export declare function attachUploadAbortStdinListener(input: Pick<NodeJS.ReadStream, 'on' | 'off' | 'pause'>, onAbort: (signal: NodeJS.Signals) => void): () => void;
59
61
  export declare function collectUploadAssets(data: RecordingData, configDir: string): Promise<PreparedUploadAsset[]>;
60
62
  export declare function stripVoicePath(voice: VoiceKey | RecordingCustomVoiceRef): VoiceKey | RecordingCustomVoiceRef;
@@ -72,6 +74,7 @@ export declare function uploadRecordings(screenciDir: string, projectName: strin
72
74
  message: string;
73
75
  }>;
74
76
  studioNotices: StudioUploadNotice[];
77
+ plan: OrgPlan | null;
75
78
  }>;
76
79
  export declare function getDevBackendUrl(): string;
77
80
  export declare function getDevFrontendUrl(): string;
@@ -82,6 +85,7 @@ export declare function extractMockRecordLiteral(configSource: string): boolean
82
85
  export declare function getConfigModuleSpecifier(resolvedConfigPath: string): string;
83
86
  export declare function ensureScreenciSecret(resolvedConfigPath?: string, opts?: {
84
87
  interactive?: boolean;
88
+ pollAuth?: boolean;
85
89
  }): Promise<string | undefined>;
86
90
  export declare function main(): Promise<void>;
87
91
  export declare function logCliError(error: unknown): void;
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../cli.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../cli.ts"],"names":[],"mappings":"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"}
package/dist/cli.js CHANGED
@@ -23,6 +23,10 @@ const SCREENCI_DEVELOPMENT_BACKEND_URL = 'https://dev.api.screenci.com';
23
23
  const SCREENCI_DEVELOPMENT_FRONTEND_URL = 'https://dev.app.screenci.com';
24
24
  const SCREENCI_LINK_SESSION_FILE = 'link-session.json';
25
25
  const SCREENCI_LINK_SESSION_POLL_INTERVAL_MS = 2_000;
26
+ // `record --poll` keeps a non-interactive session (an agent or CI) waiting for
27
+ // sign-in. We poll on a slower cadence than the interactive loop so a long wait
28
+ // for a human to click the link does not hammer the backend.
29
+ const SCREENCI_LINK_SESSION_POLL_FLAG_INTERVAL_MS = 5_000;
26
30
  const require = createRequire(import.meta.url);
27
31
  function parseScreenCIEnvironment(value) {
28
32
  if (value === undefined)
@@ -88,13 +92,14 @@ function extractPlaywrightDiscoveryError(output) {
88
92
  function logScreenCISecretGuide() {
89
93
  logger.info(`Guide: ${pc.cyan(SCREENCI_RECORD_DOCS_URL)}`);
90
94
  }
91
- function getSuggestedScreenciCommand(command) {
95
+ function getSuggestedScreenciCommand(command, flags = '') {
96
+ const suffix = flags ? ` ${flags}` : '';
92
97
  const pm = determinePackageManager();
93
98
  if (pm === 'pnpm')
94
- return `pnpm exec screenci ${command}`;
99
+ return `pnpm exec screenci ${command}${suffix}`;
95
100
  if (pm === 'yarn')
96
- return `yarn screenci ${command}`;
97
- return `npx screenci ${command}`;
101
+ return `yarn screenci ${command}${suffix}`;
102
+ return `npx screenci ${command}${suffix}`;
98
103
  }
99
104
  async function collectDiscoveredTestTitles(configPath, additionalArgs, env) {
100
105
  const listArgs = [
@@ -278,6 +283,20 @@ function isRecordFailureHintError(err) {
278
283
  function isUploadAssetError(err) {
279
284
  return err instanceof UploadAssetError;
280
285
  }
286
+ export async function withUploadRetry(fn, signal, maxAttempts = 3) {
287
+ let lastErr;
288
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
289
+ try {
290
+ return await fn();
291
+ }
292
+ catch (err) {
293
+ if (isUploadCancelledError(err))
294
+ throw err;
295
+ lastErr = err;
296
+ }
297
+ }
298
+ throw lastErr;
299
+ }
281
300
  function formatUploadProgressLine(videoName, status) {
282
301
  switch (status) {
283
302
  case undefined:
@@ -346,6 +365,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
346
365
  const { entry, videoName, data, preparedUploadAssets } = candidate;
347
366
  let projectId = null;
348
367
  let videoId = null;
368
+ let plan = null;
349
369
  try {
350
370
  uploadAbort.throwIfAborted();
351
371
  const recordingPath = resolve(screenciDir, entry, 'recording.mp4');
@@ -361,7 +381,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
361
381
  };
362
382
  }
363
383
  const recordingHash = await hashFile(recordingPath);
364
- const startResponse = await fetch(`${apiUrl}/cli/upload/start`, {
384
+ const startResponse = await withUploadRetry(() => fetch(`${apiUrl}/cli/upload/start`, {
365
385
  method: 'POST',
366
386
  headers: {
367
387
  'Content-Type': 'application/json',
@@ -387,7 +407,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
387
407
  })),
388
408
  }),
389
409
  signal: uploadAbort.signal,
390
- });
410
+ }), uploadAbort.signal);
391
411
  if (!startResponse.ok) {
392
412
  const text = await startResponse.text();
393
413
  progressReporter.complete(progressIndex, 'failure');
@@ -404,6 +424,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
404
424
  const { recordingId } = startBody;
405
425
  projectId = startBody.projectId;
406
426
  videoId = startBody.videoId ?? null;
427
+ plan = startBody.plan ?? null;
407
428
  const studio = startBody.studio;
408
429
  if (verbose) {
409
430
  logger.info(`recordingId=${recordingId} projectId=${projectId}`);
@@ -415,44 +436,45 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
415
436
  if (verbose) {
416
437
  logger.info(`Uploading recording.mp4 size=${(fileStat.size / 1024 / 1024).toFixed(1)}MB`);
417
438
  }
418
- const stream = createReadStream(recordingPath);
419
- const abortStream = () => {
420
- stream.destroy(new UploadCancelledError(`Upload cancelled for "${videoName}"`));
421
- };
422
- uploadAbort.signal.addEventListener('abort', abortStream, {
423
- once: true,
424
- });
425
- try {
426
- const recordingResponse = await fetch(`${apiUrl}/cli/upload/${recordingId}/recording`, {
427
- method: 'PUT',
428
- headers: {
429
- 'Content-Type': 'video/mp4',
430
- 'Content-Length': String(fileStat.size),
431
- 'X-ScreenCI-Secret': secret,
432
- ...(elevenLabsApiKey
433
- ? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
434
- : {}),
435
- },
436
- body: stream,
437
- signal: uploadAbort.signal,
438
- // @ts-expect-error Node.js fetch supports duplex for streaming
439
- duplex: 'half',
440
- });
441
- if (!recordingResponse.ok) {
442
- const text = await recordingResponse.text();
443
- progressReporter.complete(progressIndex, 'failure');
444
- return {
445
- projectId,
446
- videoId,
447
- hadFailure: true,
448
- videoName,
449
- failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
450
- recordId,
451
- };
439
+ const recordingResponse = await withUploadRetry(async () => {
440
+ const stream = createReadStream(recordingPath);
441
+ const abortStream = () => {
442
+ stream.destroy(new UploadCancelledError(`Upload cancelled for "${videoName}"`));
443
+ };
444
+ uploadAbort.signal.addEventListener('abort', abortStream, { once: true });
445
+ try {
446
+ return await fetch(`${apiUrl}/cli/upload/${recordingId}/recording`, {
447
+ method: 'PUT',
448
+ headers: {
449
+ 'Content-Type': 'video/mp4',
450
+ 'Content-Length': String(fileStat.size),
451
+ 'X-ScreenCI-Secret': secret,
452
+ ...(elevenLabsApiKey
453
+ ? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
454
+ : {}),
455
+ },
456
+ body: stream,
457
+ signal: uploadAbort.signal,
458
+ // @ts-expect-error Node.js fetch supports duplex for streaming
459
+ duplex: 'half',
460
+ });
452
461
  }
453
- }
454
- finally {
455
- uploadAbort.signal.removeEventListener('abort', abortStream);
462
+ finally {
463
+ uploadAbort.signal.removeEventListener('abort', abortStream);
464
+ }
465
+ }, uploadAbort.signal);
466
+ if (!recordingResponse.ok) {
467
+ const text = await recordingResponse.text();
468
+ progressReporter.complete(progressIndex, 'failure');
469
+ return {
470
+ projectId,
471
+ videoId,
472
+ hadFailure: true,
473
+ videoName,
474
+ failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
475
+ recordId,
476
+ ...(plan !== null && { plan }),
477
+ };
456
478
  }
457
479
  progressReporter.complete(progressIndex, 'success');
458
480
  cleanupUploadedRecordingDir(screenciDir, entry);
@@ -463,6 +485,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
463
485
  videoName,
464
486
  recordId,
465
487
  ...(studio !== undefined && { studio }),
488
+ ...(plan !== null && { plan }),
466
489
  };
467
490
  }
468
491
  catch (err) {
@@ -479,6 +502,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
479
502
  videoName,
480
503
  failureMessage: err instanceof Error ? err.message : String(err),
481
504
  recordId,
505
+ ...(plan !== null && { plan }),
482
506
  };
483
507
  }
484
508
  progressReporter.complete(progressIndex, 'failure');
@@ -489,6 +513,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
489
513
  videoName,
490
514
  failureMessage: `Network error uploading "${videoName}": ${err instanceof Error ? err.message : String(err)}`,
491
515
  recordId,
516
+ ...(plan !== null && { plan }),
492
517
  };
493
518
  }
494
519
  }
@@ -1022,7 +1047,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
1022
1047
  for (const asset of assets) {
1023
1048
  throwIfAborted();
1024
1049
  try {
1025
- const checkRes = await fetch(`${apiUrl}/cli/upload/${recordingId}/asset/check`, {
1050
+ const checkRes = await withUploadRetry(() => fetch(`${apiUrl}/cli/upload/${recordingId}/asset/check`, {
1026
1051
  method: 'POST',
1027
1052
  headers: {
1028
1053
  'Content-Type': 'application/json',
@@ -1036,7 +1061,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
1036
1061
  ...(typeof asset.name === 'string' ? { name: asset.name } : {}),
1037
1062
  }),
1038
1063
  signal,
1039
- });
1064
+ }), signal);
1040
1065
  if (!checkRes.ok) {
1041
1066
  const text = await checkRes.text();
1042
1067
  throw new UploadAssetError(`Failed to check asset ${asset.path}: ${checkRes.status} ${text}${hint401(checkRes.status, secret)}`);
@@ -1049,8 +1074,9 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
1049
1074
  if (!asset.fileBuffer || !asset.contentType) {
1050
1075
  throw new UploadAssetError(`Asset bytes not available for upload and backend does not have it yet: ${asset.path}`);
1051
1076
  }
1077
+ const fileBuffer = asset.fileBuffer;
1052
1078
  throwIfAborted();
1053
- const res = await fetch(`${apiUrl}/cli/upload/${recordingId}/asset`, {
1079
+ const res = await withUploadRetry(() => fetch(`${apiUrl}/cli/upload/${recordingId}/asset`, {
1054
1080
  method: 'PUT',
1055
1081
  headers: {
1056
1082
  'Content-Type': 'application/json',
@@ -1058,14 +1084,14 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
1058
1084
  },
1059
1085
  body: JSON.stringify({
1060
1086
  fileHash: asset.fileHash,
1061
- fileBase64: asset.fileBuffer.toString('base64'),
1087
+ fileBase64: fileBuffer.toString('base64'),
1062
1088
  contentType: asset.contentType,
1063
1089
  size: asset.size,
1064
1090
  path: asset.path,
1065
1091
  ...(typeof asset.name === 'string' ? { name: asset.name } : {}),
1066
1092
  }),
1067
1093
  signal,
1068
- });
1094
+ }), signal);
1069
1095
  if (!res.ok) {
1070
1096
  const text = await res.text();
1071
1097
  if (res.status === 409 && text.includes('already exists')) {
@@ -1106,6 +1132,7 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
1106
1132
  failedVideoNames: [],
1107
1133
  failedVideoMessages: [],
1108
1134
  studioNotices: [],
1135
+ plan: null,
1109
1136
  };
1110
1137
  }
1111
1138
  if (specificEntry !== undefined) {
@@ -1126,12 +1153,14 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
1126
1153
  failedVideoNames: [],
1127
1154
  failedVideoMessages: [],
1128
1155
  studioNotices: [],
1156
+ plan: null,
1129
1157
  };
1130
1158
  }
1131
1159
  const progressReporter = createUploadProgressReporter(candidates.map((candidate) => candidate.videoName), verbose);
1132
1160
  const results = await Promise.all(candidates.map(async (candidate, index) => await uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, elevenLabsApiKey, verbose, uploadAbort, progressReporter, index, recordId)));
1133
1161
  firstProjectId =
1134
1162
  results.find((result) => result.projectId !== null)?.projectId ?? null;
1163
+ const resolvedPlan = results.find((result) => result.plan !== undefined)?.plan ?? null;
1135
1164
  const hadFailures = results.some((result) => result.hadFailure);
1136
1165
  const failedVideoNames = results
1137
1166
  .filter((result) => result.hadFailure)
@@ -1155,6 +1184,7 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
1155
1184
  failedVideoNames,
1156
1185
  failedVideoMessages,
1157
1186
  studioNotices,
1187
+ plan: resolvedPlan,
1158
1188
  };
1159
1189
  }
1160
1190
  finally {
@@ -1417,8 +1447,10 @@ async function requireScreenCISecret(configPath, opts = {}) {
1417
1447
  if (!secret) {
1418
1448
  // In a non-interactive session ensureScreenciSecret already printed the
1419
1449
  // sign-in link and the next step, so we exit without repeating guidance.
1450
+ // A pending sign-in is an expected handoff (surface the link, then rerun),
1451
+ // not a failure, so exit cleanly (0) to avoid flagging the run as red.
1420
1452
  if (opts.interactive === false) {
1421
- process.exit(1);
1453
+ process.exit(0);
1422
1454
  }
1423
1455
  const envFilePath = await resolveProjectEnvFilePath(resolvedConfigPath);
1424
1456
  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)}.`);
@@ -1556,7 +1588,7 @@ async function pollLinkSessionOnce(spec) {
1556
1588
  }
1557
1589
  return { status };
1558
1590
  }
1559
- async function pollLinkSession(spec) {
1591
+ async function pollLinkSession(spec, pollIntervalMs = SCREENCI_LINK_SESSION_POLL_INTERVAL_MS) {
1560
1592
  for (;;) {
1561
1593
  const result = await pollLinkSessionOnce(spec);
1562
1594
  if (result.status === 'completed' && result.secret) {
@@ -1567,11 +1599,12 @@ async function pollLinkSession(spec) {
1567
1599
  result.status === 'invalid') {
1568
1600
  return result;
1569
1601
  }
1570
- await new Promise((resolveDelay) => setTimeout(resolveDelay, SCREENCI_LINK_SESSION_POLL_INTERVAL_MS));
1602
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, pollIntervalMs));
1571
1603
  }
1572
1604
  }
1573
1605
  export async function ensureScreenciSecret(resolvedConfigPath, opts = {}) {
1574
1606
  const interactive = opts.interactive ?? true;
1607
+ const pollAuth = opts.pollAuth ?? false;
1575
1608
  const existingSecret = process.env.SCREENCI_SECRET;
1576
1609
  if (existingSecret)
1577
1610
  return existingSecret;
@@ -1614,12 +1647,14 @@ export async function ensureScreenciSecret(resolvedConfigPath, opts = {}) {
1614
1647
  return secret;
1615
1648
  };
1616
1649
  if (!interactive) {
1617
- // Non-interactive sessions cannot complete a browser sign-in here, so we
1618
- // never block. Reuse or create the persisted session and check its status
1619
- // once; if a stored session is stale, recreate it and check once more.
1620
- // When the session is already completed (the sign-in happened between
1621
- // runs) we pick up the secret; otherwise we print the link and return so
1622
- // the caller can surface it and rerun later.
1650
+ // Non-interactive sessions cannot complete a browser sign-in here, so by
1651
+ // default we never block. Reuse or create the persisted session and check
1652
+ // its status once; if a stored session is stale, recreate it and check
1653
+ // once more. When the session is already completed (the sign-in happened
1654
+ // between runs) we pick up the secret; otherwise we print the link and
1655
+ // return so the caller can surface it and rerun later. The exception is
1656
+ // `pollAuth` (the `--poll-auth` flag), which opts in to waiting: it keeps
1657
+ // polling on a slow cadence until sign-in completes.
1623
1658
  let spec = await ensureSpec();
1624
1659
  let result = await pollLinkSessionOnce(spec);
1625
1660
  if (result.status === 'expired' ||
@@ -1632,8 +1667,25 @@ export async function ensureScreenciSecret(resolvedConfigPath, opts = {}) {
1632
1667
  if (result.status === 'completed' && result.secret) {
1633
1668
  return await saveCompletedSecret(result.secret);
1634
1669
  }
1635
- logger.info(`Sign-in required to record. Open this link to sign in and choose a plan:\n${pc.cyan(spec.appUrl)}\n` +
1636
- `This session is non-interactive, so sign-in can't complete here. After signing in, rerun ${pc.cyan(getSuggestedScreenciCommand('record'))} to continue.`);
1670
+ if (pollAuth) {
1671
+ // The caller asked us to wait for sign-in. Print the link, then poll on
1672
+ // a slow cadence until the human finishes signing in, and continue
1673
+ // recording automatically once the secret lands. We re-print the link
1674
+ // on every (re)created session so the latest valid link is always
1675
+ // visible, including after a stale session is recreated.
1676
+ for (;;) {
1677
+ logger.info(`Sign-in required to record. Open this link to sign in:\n${pc.cyan(spec.appUrl)}\n` +
1678
+ `Waiting for sign-in (checking every 5 seconds). Recording continues automatically once you finish.`);
1679
+ const polled = await pollLinkSession(spec, SCREENCI_LINK_SESSION_POLL_FLAG_INTERVAL_MS);
1680
+ if (polled.status === 'completed' && polled.secret) {
1681
+ return await saveCompletedSecret(polled.secret);
1682
+ }
1683
+ deletePersistedLinkSessionSpec(specPath);
1684
+ spec = await ensureSpec();
1685
+ }
1686
+ }
1687
+ logger.info(`Sign-in required to record. Open this link to sign in:\n${pc.cyan(spec.appUrl)}\n` +
1688
+ `This session is non-interactive, so sign-in can't complete here. After signing in, rerun ${pc.cyan(getSuggestedScreenciCommand('record'))} to continue, or run ${pc.cyan(getSuggestedScreenciCommand('record', '--poll-auth'))} to print the link again and wait for sign-in, continuing automatically.`);
1637
1689
  return undefined;
1638
1690
  }
1639
1691
  for (;;) {
@@ -1669,12 +1721,13 @@ export async function main() {
1669
1721
  .command('record [playwrightArgs...]')
1670
1722
  .description('Record videos using Playwright')
1671
1723
  .option('-v, --verbose', 'verbose output')
1724
+ .option('--poll-auth', 'wait for sign-in to complete (polling every 5s) instead of exiting, then continue recording')
1672
1725
  .allowUnknownOption(true)
1673
1726
  .action(async () => {
1674
1727
  const parsed = parseRecordCliArgs(getSubcommandArgv('record'));
1675
1728
  let playwrightFailure = null;
1676
1729
  try {
1677
- await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose);
1730
+ await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose, false, parsed.pollAuth);
1678
1731
  }
1679
1732
  catch (error) {
1680
1733
  if (!(error instanceof Error))
@@ -1727,6 +1780,7 @@ export async function main() {
1727
1780
  failedVideoNames: [],
1728
1781
  failedVideoMessages: [],
1729
1782
  studioNotices: [],
1783
+ plan: null,
1730
1784
  };
1731
1785
  try {
1732
1786
  uploadResult = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret);
@@ -1737,7 +1791,7 @@ export async function main() {
1737
1791
  }
1738
1792
  throw err;
1739
1793
  }
1740
- const { projectId, recordId, hadFailures, failedVideoNames, failedVideoMessages, studioNotices, } = uploadResult;
1794
+ const { projectId, recordId, hadFailures, failedVideoNames, failedVideoMessages, studioNotices, plan, } = uploadResult;
1741
1795
  if (recordId !== null && projectId !== null) {
1742
1796
  const recordUrl = `${appUrl}/record/${recordId}`;
1743
1797
  await writeGitHubProjectOutput(recordUrl);
@@ -1756,6 +1810,11 @@ export async function main() {
1756
1810
  : 'Recording finished, rendering in progress. Results available at:');
1757
1811
  logger.info(pc.cyan(projectUrl));
1758
1812
  }
1813
+ if (projectId !== null && plan !== 'business') {
1814
+ logger.info('');
1815
+ logger.info('Upgrade for more renders, more active videos, and expressive narration:');
1816
+ logger.info(pc.cyan(`${appUrl}/select-plan`));
1817
+ }
1759
1818
  for (const notice of studioNotices) {
1760
1819
  if ('held' in notice.studio) {
1761
1820
  logger.info('');
@@ -1900,6 +1959,7 @@ function getSubcommandArgv(command) {
1900
1959
  function parseRecordCliArgs(args) {
1901
1960
  let configPath;
1902
1961
  let verbose = false;
1962
+ let pollAuth = false;
1903
1963
  const otherArgs = [];
1904
1964
  for (let i = 0; i < args.length; i++) {
1905
1965
  const arg = args[i];
@@ -1917,6 +1977,9 @@ function parseRecordCliArgs(args) {
1917
1977
  else if (arg === '--verbose' || arg === '-v') {
1918
1978
  verbose = true;
1919
1979
  }
1980
+ else if (arg === '--poll-auth') {
1981
+ pollAuth = true;
1982
+ }
1920
1983
  else {
1921
1984
  otherArgs.push(arg);
1922
1985
  }
@@ -1924,6 +1987,7 @@ function parseRecordCliArgs(args) {
1924
1987
  return {
1925
1988
  configPath,
1926
1989
  verbose,
1990
+ pollAuth,
1927
1991
  otherArgs,
1928
1992
  };
1929
1993
  }
@@ -1974,7 +2038,7 @@ function validateArgs(args) {
1974
2038
  }
1975
2039
  }
1976
2040
  }
1977
- async function run(command, additionalArgs, customConfigPath, verbose = false, mockRecord = false) {
2041
+ async function run(command, additionalArgs, customConfigPath, verbose = false, mockRecord = false, pollAuth = false) {
1978
2042
  const configPath = resolveScreenCIConfigPathOrExit(customConfigPath);
1979
2043
  if (command === 'test' || process.env.SCREENCI_RECORDING !== 'true') {
1980
2044
  await loadEnvFileFromConfigSource(configPath, false);
@@ -1984,6 +2048,7 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
1984
2048
  if (!process.env.SCREENCI_SECRET) {
1985
2049
  await requireScreenCISecret(configPath, {
1986
2050
  interactive: detectInteractiveSession(),
2051
+ pollAuth,
1987
2052
  });
1988
2053
  }
1989
2054
  validateArgs(additionalArgs);