screenci 0.0.78 → 0.0.80

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.js CHANGED
@@ -13,9 +13,8 @@ import { determinePackageManager, parsePackageManager, runInit, } from './src/in
13
13
  import { SCREENCI_DISABLE_RECORDING_TIMINGS_ENV, SCREENCI_MOCK_RECORD_ENV, SCREENCI_LANGUAGES_ENV, SCREENCI_VALUES_OVERRIDES_ENV, SCREENCI_RECORD_OPTIONS_ENV, isUploadExistingEnabled, } from './src/runtimeMode.js';
14
14
  import { DEFAULT_RECORD_UPLOAD_POLICY } from './src/defaults.js';
15
15
  import { findDuplicateTitles, formatDuplicateTitlesMessage, } from './src/titleValidation.js';
16
- import { createLinkSessionSpec, deletePersistedLinkSessionSpec, getCliLinkSessionApiUrl, getDevBackendUrl, getDevFrontendUrl, getLinkSessionFilePath, getScreenCIEnvironment, isStoredLinkSessionReusable, readPersistedLinkSessionSpec, SCREENCI_LINK_SESSION_FILE, writePersistedLinkSessionSpec, } from './src/linkSession.js';
16
+ import { getCliLinkSessionApiUrl, getDevBackendUrl, getDevFrontendUrl, getScreenCISecretsUrl, } from './src/linkSession.js';
17
17
  import { OVERLAY_CACHE_DIR_NAME } from './src/htmlRasterizer.js';
18
- import { openUrlInBrowser } from './src/openBrowser.js';
19
18
  import { maybeExtractVoiceSampleAudio } from './src/voiceSampleAudio.js';
20
19
  // Re-export the environment-aware URL helpers so existing importers (and tests)
21
20
  // can keep importing them from the CLI entrypoint.
@@ -25,20 +24,6 @@ const SCREENCI_RECORD_DOCS_URL = 'https://screenci.com/docs/reference/cli/#scree
25
24
  // Records the recordId of the most recent `screenci record` upload so
26
25
  // `screenci info` can report exactly the run that was just made.
27
26
  const SCREENCI_LAST_RECORD_FILE = 'last-record.json';
28
- const SCREENCI_LINK_SESSION_POLL_INTERVAL_MS = 2_000;
29
- // `record --poll` keeps a non-interactive session (an agent or CI) waiting for
30
- // sign-in. We poll on a slower cadence than the interactive loop so a long wait
31
- // for a human to click the link does not hammer the backend.
32
- const SCREENCI_LINK_SESSION_POLL_FLAG_INTERVAL_MS = 5_000;
33
- // A waiting `record` does not wait forever: after this long without a completed
34
- // sign-in we stop polling so a non-interactive agent run does not hang. The link
35
- // stays valid, so the command can be rerun. The default can be overridden with
36
- // SCREENCI_POLL_AUTH_TIMEOUT_MS (milliseconds).
37
- const SCREENCI_LINK_SESSION_POLL_FLAG_TIMEOUT_MS = 5 * 60 * 1_000;
38
- // An interactive `record` (a human at a terminal) waits longer before giving up,
39
- // since a person may take a while to open the link and finish signing in. Also
40
- // overridable with SCREENCI_POLL_AUTH_TIMEOUT_MS.
41
- const SCREENCI_LINK_SESSION_INTERACTIVE_TIMEOUT_MS = 15 * 60 * 1_000;
42
27
  const require = createRequire(import.meta.url);
43
28
  /**
44
29
  * Reports whether the current session can complete an interactive browser
@@ -264,17 +249,6 @@ class RecordFailureHintError extends Error {
264
249
  this.cause = cause;
265
250
  }
266
251
  }
267
- // Thrown when we wait for a browser sign-in and the deadline passes before it
268
- // completes. The link stays valid, so the message tells the user to sign in and
269
- // rerun. A timed-out sign-in means recording did not happen, so the caller maps
270
- // this to a non-zero exit (it is a failure, not the clean print-and-exit handoff
271
- // used by CI).
272
- export class SignInTimeoutError extends Error {
273
- constructor(message) {
274
- super(message);
275
- this.name = 'SignInTimeoutError';
276
- }
277
- }
278
252
  function isUploadCancelledError(err) {
279
253
  return (err instanceof UploadCancelledError ||
280
254
  (err instanceof Error &&
@@ -444,6 +418,20 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
444
418
  if (!startResponse.ok) {
445
419
  const text = await startResponse.text();
446
420
  progressReporter.complete(progressIndex, 'failure');
421
+ // A missing ElevenLabs key fails the render server-side and is reported
422
+ // once in the final summary (see uploadRecordings) as a dedicated error
423
+ // with the Secrets link, so it carries the flag instead of a generic
424
+ // upload-failure message that would duplicate it.
425
+ if (responseFlagsElevenLabsKeyMissing(text)) {
426
+ return {
427
+ projectId: null,
428
+ videoId: null,
429
+ hadFailure: true,
430
+ elevenLabsKeyMissing: true,
431
+ videoName,
432
+ recordId,
433
+ };
434
+ }
447
435
  return {
448
436
  projectId: null,
449
437
  videoId: null,
@@ -467,8 +455,9 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
467
455
  logger.error(`Render dependency error in "${videoName}": ${depError.detail}. This render will fail until it is fixed.`);
468
456
  }
469
457
  }
470
- // The missing-ElevenLabs-key warning is surfaced once in the final summary
471
- // (see uploadRecordings), where it is not overwritten by the upload spinner.
458
+ // A missing ElevenLabs key is a hard failure returned as an error response
459
+ // (handled in the !startResponse.ok branch above), surfaced once in the
460
+ // final summary where it is not overwritten by the upload spinner.
472
461
  if (verbose) {
473
462
  logger.info(`recordingId=${recordingId} projectId=${projectId}`);
474
463
  logger.info(`assets=${preparedUploadAssets.length} recordingHash=${recordingHash ?? 'none'}`);
@@ -526,9 +515,6 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
526
515
  recordId,
527
516
  ...(studio !== undefined && { studio }),
528
517
  ...(plan !== null && { plan }),
529
- ...(startBody.elevenLabsKeyMissing === true && {
530
- elevenLabsKeyMissing: true,
531
- }),
532
518
  ...(Array.isArray(startBody.notices) &&
533
519
  startBody.notices.length > 0 && {
534
520
  notices: startBody.notices.filter((notice) => typeof notice === 'string'),
@@ -759,8 +745,6 @@ function forwardChildSignals(child, activityLabel, options = {}) {
759
745
  export function clearRecordingDirectories(dir) {
760
746
  mkdirSync(dir, { recursive: true });
761
747
  for (const entry of readdirSync(dir)) {
762
- if (entry === SCREENCI_LINK_SESSION_FILE)
763
- continue;
764
748
  // Preserve the cross-run overlay cache: it lives as a sibling of the
765
749
  // per-recording directories so unchanged overlays are served byte for byte
766
750
  // from a previous run. Wiping it would re-render and re-encode every overlay
@@ -1243,6 +1227,23 @@ export function extractBackendError(responseText) {
1243
1227
  }
1244
1228
  return responseText;
1245
1229
  }
1230
+ /**
1231
+ * Whether an upload-start error body flags a missing ElevenLabs key. The backend
1232
+ * fails the render immediately and replies with `{ elevenLabsKeyMissing: true }`
1233
+ * so the CLI can surface the dedicated Secrets-link error instead of a generic
1234
+ * upload failure. Non-JSON or shapeless bodies are treated as not flagged.
1235
+ */
1236
+ export function responseFlagsElevenLabsKeyMissing(responseText) {
1237
+ if (responseText.trim().length === 0)
1238
+ return false;
1239
+ try {
1240
+ const parsed = JSON.parse(responseText);
1241
+ return parsed.elevenLabsKeyMissing === true;
1242
+ }
1243
+ catch {
1244
+ return false;
1245
+ }
1246
+ }
1246
1247
  export function formatUploadStartFailureMessage(videoName, status, responseText, secret) {
1247
1248
  if (responseText.trim().length > 0) {
1248
1249
  try {
@@ -1518,9 +1519,7 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
1518
1519
  },
1519
1520
  ]
1520
1521
  : []);
1521
- const elevenLabsKeyMissingVideos = results.flatMap((result) => !result.hadFailure && result.elevenLabsKeyMissing === true
1522
- ? [result.videoName]
1523
- : []);
1522
+ const elevenLabsKeyMissingVideos = results.flatMap((result) => result.elevenLabsKeyMissing === true ? [result.videoName] : []);
1524
1523
  const notices = results.flatMap((result) => !result.hadFailure && result.notices !== undefined ? result.notices : []);
1525
1524
  return {
1526
1525
  projectId: firstProjectId,
@@ -1548,9 +1547,6 @@ async function countCompletedRecordings(screenciDir) {
1548
1547
  }
1549
1548
  return entries.filter((entry) => existsSync(resolve(screenciDir, entry, 'data.json'))).length;
1550
1549
  }
1551
- function getScreenCISecretsUrl() {
1552
- return `${getDevFrontendUrl()}/secrets`;
1553
- }
1554
1550
  async function writeGitHubProjectOutput(projectUrl) {
1555
1551
  const githubOutput = process.env.GITHUB_OUTPUT;
1556
1552
  if (!githubOutput)
@@ -1760,34 +1756,15 @@ async function loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath) {
1760
1756
  throw err;
1761
1757
  }
1762
1758
  }
1763
- async function requireScreenCISecret(configPath, opts = {}) {
1759
+ export async function requireScreenCISecret(configPath) {
1764
1760
  const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
1765
- let secret;
1766
- try {
1767
- secret =
1768
- process.env.SCREENCI_SECRET ??
1769
- (await ensureScreenciSecret(resolvedConfigPath, opts));
1770
- }
1771
- catch (err) {
1772
- // A waited-out sign-in means recording did not happen: print the (already
1773
- // formatted) message and exit non-zero so the failure is loud, instead of
1774
- // looking like a successful run.
1775
- if (err instanceof SignInTimeoutError) {
1776
- logger.error(err.message);
1777
- process.exit(1);
1778
- }
1779
- throw err;
1780
- }
1761
+ const secret = process.env.SCREENCI_SECRET;
1781
1762
  if (!secret) {
1782
- // In a non-interactive session ensureScreenciSecret already printed the
1783
- // sign-in link and the next step, so we exit without repeating guidance.
1784
- // A pending sign-in is an expected handoff (surface the link, then rerun),
1785
- // not a failure, so exit cleanly (0) to avoid flagging the run as red.
1786
- if (opts.interactive === false) {
1787
- process.exit(0);
1788
- }
1763
+ // No browser sign-in flow: the secret comes from an init one-time token or
1764
+ // from the secrets page. Guide the user to either path and exit non-zero.
1789
1765
  const envFilePath = await resolveProjectEnvFilePath(resolvedConfigPath);
1790
- 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)}.`);
1766
+ logger.error(`No SCREENCI_SECRET configured. Copy your secret from ${pc.cyan(getScreenCISecretsUrl())} into ${envFilePath}, or re-run init with a one-time setup token from ${pc.cyan(getScreenCISecretsUrl())}.`);
1767
+ logScreenCISecretGuide();
1791
1768
  process.exit(1);
1792
1769
  }
1793
1770
  return {
@@ -1798,9 +1775,7 @@ async function requireScreenCISecret(configPath, opts = {}) {
1798
1775
  };
1799
1776
  }
1800
1777
  async function updateVideoVisibility(videoId, isPublic, configPath) {
1801
- const { secret, apiUrl } = await requireScreenCISecret(configPath, {
1802
- interactive: detectInteractiveSession(),
1803
- });
1778
+ const { secret, apiUrl } = await requireScreenCISecret(configPath);
1804
1779
  const method = isPublic ? 'PUT' : 'DELETE';
1805
1780
  const res = await fetch(`${apiUrl}/cli/public-video/${videoId}`, {
1806
1781
  method,
@@ -1837,7 +1812,7 @@ export function extractGrep(args) {
1837
1812
  // token stored for the project. An optional `--grep` records only matching
1838
1813
  // videos/screenshots.
1839
1814
  async function triggerRemoteRun(configPath, grep, languages) {
1840
- const { screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath, { interactive: detectInteractiveSession() });
1815
+ const { screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath);
1841
1816
  const res = await fetch(`${apiUrl}/cli/trigger-run`, {
1842
1817
  method: 'POST',
1843
1818
  headers: {
@@ -1899,9 +1874,7 @@ async function readLastRecordId(screenciDir) {
1899
1874
  // URLs. Without a local run, only the project-wide listing with `static` URLs is
1900
1875
  // returned. The server does the merge; the CLI just passes the recordId.
1901
1876
  async function printInfo(configPath) {
1902
- const { resolvedConfigPath, screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath, {
1903
- interactive: detectInteractiveSession(),
1904
- });
1877
+ const { resolvedConfigPath, screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath);
1905
1878
  const screenciDir = resolve(dirname(resolvedConfigPath), '.screenci');
1906
1879
  const recordId = await readLastRecordId(screenciDir);
1907
1880
  const url = new URL(`${apiUrl}/cli/info`);
@@ -1918,268 +1891,6 @@ async function printInfo(configPath) {
1918
1891
  const data = await res.json();
1919
1892
  process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
1920
1893
  }
1921
- async function persistScreenCISecret(envFilePath, secret) {
1922
- const nextLine = `SCREENCI_SECRET=${secret}`;
1923
- try {
1924
- const existing = await readFile(envFilePath, 'utf-8');
1925
- const lines = existing === '' ? [] : existing.split(/\r?\n/);
1926
- const firstSecretIndex = lines.findIndex((line) => line.startsWith('SCREENCI_SECRET='));
1927
- const linesWithoutSecret = lines.filter((line) => !line.startsWith('SCREENCI_SECRET='));
1928
- const finalLines = firstSecretIndex >= 0
1929
- ? [
1930
- ...linesWithoutSecret.slice(0, firstSecretIndex),
1931
- nextLine,
1932
- ...linesWithoutSecret.slice(firstSecretIndex),
1933
- ]
1934
- : [...linesWithoutSecret, nextLine];
1935
- let nextContent = finalLines.join('\n');
1936
- if (!nextContent.endsWith('\n'))
1937
- nextContent += '\n';
1938
- await writeFile(envFilePath, nextContent);
1939
- return;
1940
- }
1941
- catch (err) {
1942
- if (!isMissingFileError(err))
1943
- throw err;
1944
- }
1945
- await writeFile(envFilePath, `${nextLine}\n`);
1946
- }
1947
- async function pollLinkSessionOnce(spec) {
1948
- const response = await fetch(spec.pollUrl);
1949
- const body = (await response.json());
1950
- const status = body.status ?? 'invalid';
1951
- if (status === 'completed' && body.secret) {
1952
- return { status, secret: body.secret };
1953
- }
1954
- if (status === 'pending' && new Date().toISOString() >= spec.expiresAt) {
1955
- return { status: 'expired' };
1956
- }
1957
- return { status };
1958
- }
1959
- function getAuthPollTimeoutMs(defaultMs) {
1960
- const raw = process.env.SCREENCI_POLL_AUTH_TIMEOUT_MS;
1961
- if (raw === undefined)
1962
- return defaultMs;
1963
- const parsed = Number(raw);
1964
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : defaultMs;
1965
- }
1966
- async function pollLinkSession(spec, pollIntervalMs = SCREENCI_LINK_SESSION_POLL_INTERVAL_MS, deadlineEpochMs) {
1967
- for (;;) {
1968
- const result = await pollLinkSessionOnce(spec);
1969
- if (result.status === 'completed' && result.secret) {
1970
- return result;
1971
- }
1972
- if (result.status === 'expired' ||
1973
- result.status === 'consumed' ||
1974
- result.status === 'invalid') {
1975
- return result;
1976
- }
1977
- // Stop before sleeping again once the optional deadline has passed so
1978
- // `--poll-auth` cannot block forever waiting for a human to sign in.
1979
- if (deadlineEpochMs !== undefined && Date.now() >= deadlineEpochMs) {
1980
- return { status: 'timed-out' };
1981
- }
1982
- await new Promise((resolveDelay) => setTimeout(resolveDelay, pollIntervalMs));
1983
- }
1984
- }
1985
- export async function ensureScreenciSecret(resolvedConfigPath, opts = {}) {
1986
- const interactive = opts.interactive ?? true;
1987
- const pollAuth = opts.pollAuth ?? false;
1988
- const noPollAuth = opts.noPollAuth ?? false;
1989
- const existingSecret = process.env.SCREENCI_SECRET;
1990
- if (existingSecret)
1991
- return existingSecret;
1992
- try {
1993
- const environment = getScreenCIEnvironment();
1994
- const apiUrl = getCliLinkSessionApiUrl();
1995
- const appUrl = getDevFrontendUrl();
1996
- const envFilePath = resolvedConfigPath
1997
- ? await resolveProjectEnvFilePath(resolvedConfigPath)
1998
- : resolve(process.cwd(), '.env');
1999
- const projectDir = resolvedConfigPath
2000
- ? dirname(resolvedConfigPath)
2001
- : process.cwd();
2002
- const specPath = getLinkSessionFilePath(projectDir);
2003
- const linkSessionContext = {
2004
- environment,
2005
- envFilePath,
2006
- ...(resolvedConfigPath ? { resolvedConfigPath } : {}),
2007
- };
2008
- const ensureSpec = async () => {
2009
- const storedSpec = await readPersistedLinkSessionSpec(specPath);
2010
- const spec = storedSpec &&
2011
- isStoredLinkSessionReusable(storedSpec, linkSessionContext)
2012
- ? storedSpec
2013
- : await createLinkSessionSpec({
2014
- apiUrl,
2015
- appUrl,
2016
- ...linkSessionContext,
2017
- });
2018
- if (spec !== storedSpec) {
2019
- await writePersistedLinkSessionSpec(specPath, spec);
2020
- }
2021
- return spec;
2022
- };
2023
- const saveCompletedSecret = async (secret) => {
2024
- process.env.SCREENCI_SECRET = secret;
2025
- await persistScreenCISecret(envFilePath, secret);
2026
- deletePersistedLinkSessionSpec(specPath);
2027
- logger.info(`Successfully saved SCREENCI_SECRET to ${envFilePath}`);
2028
- return secret;
2029
- };
2030
- // Decide whether this run waits for a browser sign-in or just prints the
2031
- // link and hands off. A human at a terminal always waits. A plain
2032
- // non-interactive run (a coding agent: no terminal, no CI) also waits by
2033
- // default, so `npx screenci record` records without needing a flag. CI and
2034
- // an explicit SCREENCI_NONINTERACTIVE never wait (CI must not hang and is
2035
- // expected to provide SCREENCI_SECRET), and `--no-poll-auth` opts back into
2036
- // that print-and-exit handoff. `--poll-auth` forces waiting and is kept for
2037
- // backwards compatibility (it is the default for agents now).
2038
- const isCI = process.env.CI === 'true';
2039
- const nonInteractiveForced = process.env.SCREENCI_NONINTERACTIVE === '1';
2040
- const waitForSignIn = noPollAuth
2041
- ? false
2042
- : interactive || pollAuth || (!isCI && !nonInteractiveForced);
2043
- const pollIntervalMs = interactive
2044
- ? SCREENCI_LINK_SESSION_POLL_INTERVAL_MS
2045
- : SCREENCI_LINK_SESSION_POLL_FLAG_INTERVAL_MS;
2046
- const timeoutMs = getAuthPollTimeoutMs(interactive
2047
- ? SCREENCI_LINK_SESSION_INTERACTIVE_TIMEOUT_MS
2048
- : SCREENCI_LINK_SESSION_POLL_FLAG_TIMEOUT_MS);
2049
- // Check the current/stored session once. If sign-in already completed (for
2050
- // example between runs) we pick up the secret immediately. Recreate a stale
2051
- // session and check once more before deciding to wait or hand off.
2052
- let spec = await ensureSpec();
2053
- let result = await pollLinkSessionOnce(spec);
2054
- if (result.status === 'expired' ||
2055
- result.status === 'consumed' ||
2056
- result.status === 'invalid') {
2057
- deletePersistedLinkSessionSpec(specPath);
2058
- spec = await ensureSpec();
2059
- result = await pollLinkSessionOnce(spec);
2060
- }
2061
- if (result.status === 'completed' && result.secret) {
2062
- return await saveCompletedSecret(result.secret);
2063
- }
2064
- if (!waitForSignIn) {
2065
- // CI / SCREENCI_NONINTERACTIVE / --no-poll-auth: print the link and hand
2066
- // off without waiting. The caller exits cleanly (0); a pending sign-in
2067
- // here is an expected handoff, not a failure.
2068
- logger.info(`Sign-in required to record. Open this link to sign in:\n${pc.cyan(spec.appUrl)}\n` +
2069
- `This session won't wait for sign-in. After signing in, rerun ${pc.cyan(getSuggestedScreenciCommand('record'))} to record. In CI, set SCREENCI_SECRET (from ${getScreenCISecretsUrl()}) to record without an interactive sign-in.`);
2070
- return undefined;
2071
- }
2072
- // Wait for sign-in: print the link, then poll until the human finishes and
2073
- // continue recording automatically once the secret lands. We re-print the
2074
- // link on every (re)created session so the latest valid link is always
2075
- // visible. We do not wait forever: after the timeout we throw so the caller
2076
- // exits non-zero (a timed-out sign-in means nothing was recorded). The link
2077
- // stays valid for a rerun.
2078
- const timeoutMinutes = Math.max(1, Math.round(timeoutMs / 60_000));
2079
- const intervalSeconds = Math.max(1, Math.round(pollIntervalMs / 1_000));
2080
- const deadlineEpochMs = Date.now() + timeoutMs;
2081
- for (;;) {
2082
- logger.info(`Sign-in required to record. Open this link to sign in:\n${pc.cyan(spec.appUrl)}\n` +
2083
- `Waiting for sign-in (checking every ${intervalSeconds} seconds, up to ${timeoutMinutes} minutes). Recording continues automatically once you finish.`);
2084
- const polled = await pollLinkSession(spec, pollIntervalMs, deadlineEpochMs);
2085
- if (polled.status === 'completed' && polled.secret) {
2086
- return await saveCompletedSecret(polled.secret);
2087
- }
2088
- if (polled.status === 'timed-out' || Date.now() >= deadlineEpochMs) {
2089
- throw new SignInTimeoutError(`Timed out after ${timeoutMinutes} minutes waiting for sign-in. The link is still valid:\n${pc.cyan(spec.appUrl)}\n` +
2090
- `After signing in, rerun ${pc.cyan(getSuggestedScreenciCommand('record'))} to record.`);
2091
- }
2092
- // Session went stale (expired/consumed/invalid) before sign-in: recreate
2093
- // and keep waiting until the deadline.
2094
- deletePersistedLinkSessionSpec(specPath);
2095
- spec = await ensureSpec();
2096
- }
2097
- }
2098
- catch (err) {
2099
- // A timed-out sign-in is a deliberate, already-formatted failure: let it
2100
- // propagate so the caller can exit non-zero rather than swallowing it as a
2101
- // generic auth error.
2102
- if (err instanceof SignInTimeoutError)
2103
- throw err;
2104
- const msg = err instanceof Error ? err.message : String(err);
2105
- logger.warn(`Authentication failed: ${msg}`);
2106
- logger.info(`You can add SCREENCI_SECRET manually to ${resolvedConfigPath ? await resolveProjectEnvFilePath(resolvedConfigPath) : '.env'} later. Get it from ${getScreenCISecretsUrl()}.`);
2107
- logScreenCISecretGuide();
2108
- return undefined;
2109
- }
2110
- }
2111
- /**
2112
- * `screenci login`: get a sign-in link in front of a human as early as
2113
- * possible. It reuses the link `init` (or a prior run) persisted, or creates a
2114
- * fresh one, tries to open it in the browser best-effort, and always prints it
2115
- * as a fallback. It does NOT wait for sign-in to finish: the agent can run this,
2116
- * keep authoring and `test`ing, then `record` (which blocks for sign-in) once
2117
- * the script is green. If sign-in already completed, it saves the secret and
2118
- * exits.
2119
- */
2120
- export async function runLogin(configPath) {
2121
- const { resolvedConfigPath } = await loadScreenCIConfigAndEnv(configPath);
2122
- if (process.env.SCREENCI_SECRET) {
2123
- logger.info(`Already signed in (SCREENCI_SECRET is set). Run ${pc.cyan(getSuggestedScreenciCommand('record'))} to record.`);
2124
- return;
2125
- }
2126
- const environment = getScreenCIEnvironment();
2127
- const apiUrl = getCliLinkSessionApiUrl();
2128
- const appUrl = getDevFrontendUrl();
2129
- const envFilePath = await resolveProjectEnvFilePath(resolvedConfigPath);
2130
- const projectDir = dirname(resolvedConfigPath);
2131
- const specPath = getLinkSessionFilePath(projectDir);
2132
- const linkSessionContext = { environment, envFilePath, resolvedConfigPath };
2133
- const createSpec = async () => {
2134
- const created = await createLinkSessionSpec({
2135
- apiUrl,
2136
- appUrl,
2137
- ...linkSessionContext,
2138
- });
2139
- await writePersistedLinkSessionSpec(specPath, created);
2140
- return created;
2141
- };
2142
- try {
2143
- // Reuse a still-valid persisted session so login and a later record share
2144
- // the same link; otherwise create and persist a fresh one.
2145
- const stored = await readPersistedLinkSessionSpec(specPath);
2146
- let spec = stored && isStoredLinkSessionReusable(stored, linkSessionContext)
2147
- ? stored
2148
- : await createSpec();
2149
- // If sign-in already completed (e.g. between runs) save the secret and stop:
2150
- // there is nothing left to open. Recreate a stale session before opening so
2151
- // the link we hand off is valid.
2152
- const result = await pollLinkSessionOnce(spec);
2153
- if (result.status === 'completed' && result.secret) {
2154
- process.env.SCREENCI_SECRET = result.secret;
2155
- await persistScreenCISecret(envFilePath, result.secret);
2156
- deletePersistedLinkSessionSpec(specPath);
2157
- logger.info(`Already signed in. Saved SCREENCI_SECRET to ${envFilePath}.`);
2158
- return;
2159
- }
2160
- if (result.status === 'expired' ||
2161
- result.status === 'consumed' ||
2162
- result.status === 'invalid') {
2163
- deletePersistedLinkSessionSpec(specPath);
2164
- spec = await createSpec();
2165
- }
2166
- const opened = await openUrlInBrowser(spec.appUrl);
2167
- if (opened.opened) {
2168
- logger.info(`Opening the sign-in link in your browser:\n${pc.cyan(spec.appUrl)}`);
2169
- }
2170
- else {
2171
- logger.info(`Could not open a browser automatically (${opened.reason}). Open this link to sign in:\n${pc.cyan(spec.appUrl)}`);
2172
- }
2173
- logger.info(`The link stays valid for 24 hours. You can keep authoring and run ${pc.cyan(getSuggestedScreenciCommand('test'))} while you sign in. Then run ${pc.cyan(getSuggestedScreenciCommand('record'))}, which waits for sign-in and records once you finish.`);
2174
- }
2175
- catch (err) {
2176
- // Login is a best-effort convenience: never fail the run on a network or
2177
- // browser hiccup. record can still create and wait on the link later.
2178
- const msg = err instanceof Error ? err.message : String(err);
2179
- logger.warn(`Could not start sign-in: ${msg}`);
2180
- logger.info(`You can sign in later: ${pc.cyan(getSuggestedScreenciCommand('record'))} prints a link and waits for sign-in. In CI, set SCREENCI_SECRET (from ${getScreenCISecretsUrl()}).`);
2181
- }
2182
- }
2183
1894
  // Uploads the recordings already written under `.screenci` for the resolved
2184
1895
  // config. Shared by `record` (after a Playwright run) and `retry` (which
2185
1896
  // re-sends the existing recordings without re-running Playwright). A non-null
@@ -2209,7 +1920,7 @@ async function uploadRecordedVideosForConfig(configPath, playwrightFailure, verb
2209
1920
  logger.info('All recordings failed.');
2210
1921
  }
2211
1922
  else if (!secret) {
2212
- logger.info(`No SCREENCI_SECRET configured for uploads. Rerun ${getSuggestedScreenciCommand('record')} or add it to the project env file.`);
1923
+ logger.info(`No SCREENCI_SECRET configured for uploads. Copy your secret from ${getScreenCISecretsUrl()} into the project env file, then rerun ${getSuggestedScreenciCommand('record')}.`);
2213
1924
  }
2214
1925
  else if (playwrightFailure !== null &&
2215
1926
  uploadPolicy === 'all-or-nothing') {
@@ -2282,7 +1993,12 @@ async function uploadRecordedVideosForConfig(configPath, playwrightFailure, verb
2282
1993
  }
2283
1994
  if (projectId !== null && plan !== 'business') {
2284
1995
  logger.info('');
2285
- logger.info('Upgrade for more renders, more active videos, and expressive narration:');
1996
+ if (plan === 'free') {
1997
+ 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:');
1998
+ }
1999
+ else {
2000
+ logger.info('Upgrade for more renders, more active videos, and expressive narration:');
2001
+ }
2286
2002
  logger.info(pc.cyan(`${appUrl}/select-plan`));
2287
2003
  }
2288
2004
  for (const notice of studioNotices) {
@@ -2321,7 +2037,7 @@ async function uploadRecordedVideosForConfig(configPath, playwrightFailure, verb
2321
2037
  export async function main() {
2322
2038
  if (process.argv.length <= 2) {
2323
2039
  logger.error('Error: No command provided');
2324
- logger.error('Available commands: record, test, login, info, make-public, make-private, init');
2040
+ logger.error('Available commands: record, test, info, make-public, make-private, init');
2325
2041
  process.exit(1);
2326
2042
  }
2327
2043
  const program = new Command();
@@ -2333,8 +2049,6 @@ export async function main() {
2333
2049
  .command('record [playwrightArgs...]')
2334
2050
  .description('Record videos using Playwright')
2335
2051
  .option('-v, --verbose', 'verbose output')
2336
- .option('--poll-auth', 'wait for sign-in before recording (now the default off-CI; kept for backwards compatibility)')
2337
- .option('--no-poll-auth', 'do not wait for sign-in: print the link and exit cleanly if not signed in (the default under CI)')
2338
2052
  .option('--remote', 'trigger the GitHub Actions recording workflow for this project remotely instead of recording locally')
2339
2053
  .option('--languages <langs>', 'record/render only these languages (comma-separated, e.g. fi,en)')
2340
2054
  .allowUnknownOption(true)
@@ -2356,7 +2070,7 @@ export async function main() {
2356
2070
  const uploadExisting = isUploadExistingEnabled();
2357
2071
  if (!uploadExisting) {
2358
2072
  try {
2359
- await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose, false, parsed.pollAuth, parsed.languages, parsed.noPollAuth);
2073
+ await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose, false, parsed.languages);
2360
2074
  }
2361
2075
  catch (error) {
2362
2076
  if (!(error instanceof Error))
@@ -2411,13 +2125,6 @@ export async function main() {
2411
2125
  const recordCommand = getSuggestedScreenciCommand('record');
2412
2126
  logger.info(`Tests passed. Run ${pc.cyan(recordCommand)} to render the videos.`);
2413
2127
  });
2414
- program
2415
- .command('login')
2416
- .description('Open the sign-in link in your browser (best-effort) so recording can finish later')
2417
- .option('-c, --config <path>', 'path to screenci.config.ts')
2418
- .action(async (options) => {
2419
- await runLogin(options['config']);
2420
- });
2421
2128
  program
2422
2129
  .command('info')
2423
2130
  .description("Print the latest record run's video URLs and render status as JSON")
@@ -2495,8 +2202,6 @@ function getSubcommandArgv(command) {
2495
2202
  export function parseRecordCliArgs(args) {
2496
2203
  let configPath;
2497
2204
  let verbose = false;
2498
- let pollAuth = false;
2499
- let noPollAuth = false;
2500
2205
  let remote = false;
2501
2206
  let languages;
2502
2207
  const otherArgs = [];
@@ -2530,12 +2235,6 @@ export function parseRecordCliArgs(args) {
2530
2235
  else if (arg === '--verbose' || arg === '-v') {
2531
2236
  verbose = true;
2532
2237
  }
2533
- else if (arg === '--poll-auth') {
2534
- pollAuth = true;
2535
- }
2536
- else if (arg === '--no-poll-auth') {
2537
- noPollAuth = true;
2538
- }
2539
2238
  else if (arg === '--remote') {
2540
2239
  remote = true;
2541
2240
  }
@@ -2546,8 +2245,6 @@ export function parseRecordCliArgs(args) {
2546
2245
  return {
2547
2246
  configPath,
2548
2247
  verbose,
2549
- pollAuth,
2550
- noPollAuth,
2551
2248
  remote,
2552
2249
  languages,
2553
2250
  otherArgs,
@@ -2610,7 +2307,7 @@ function validateArgs(args) {
2610
2307
  */
2611
2308
  async function fetchTextOverridesEnv(configPath, languages, verbose) {
2612
2309
  try {
2613
- const { screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath, { interactive: false });
2310
+ const { screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath);
2614
2311
  const params = new URLSearchParams({
2615
2312
  projectName: screenciConfig.projectName,
2616
2313
  });
@@ -2650,7 +2347,7 @@ async function fetchTextOverridesEnv(configPath, languages, verbose) {
2650
2347
  */
2651
2348
  async function fetchRecordOptionsEnv(configPath, verbose) {
2652
2349
  try {
2653
- const { screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath, { interactive: false });
2350
+ const { screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath);
2654
2351
  const params = new URLSearchParams({
2655
2352
  projectName: screenciConfig.projectName,
2656
2353
  });
@@ -2678,20 +2375,14 @@ async function fetchRecordOptionsEnv(configPath, verbose) {
2678
2375
  return {};
2679
2376
  }
2680
2377
  }
2681
- async function run(command, additionalArgs, customConfigPath, verbose = false, mockRecord = false, pollAuth = false, languages, noPollAuth = false) {
2378
+ async function run(command, additionalArgs, customConfigPath, verbose = false, mockRecord = false, languages) {
2682
2379
  const configPath = resolveScreenCIConfigPathOrExit(customConfigPath);
2683
2380
  if (command === 'test' || process.env.SCREENCI_RECORDING !== 'true') {
2684
2381
  await loadEnvFileFromConfigSource(configPath, false);
2685
2382
  }
2686
2383
  // Only validate args for record command
2687
2384
  if (command === 'record') {
2688
- if (!process.env.SCREENCI_SECRET) {
2689
- await requireScreenCISecret(configPath, {
2690
- interactive: detectInteractiveSession(),
2691
- pollAuth,
2692
- noPollAuth,
2693
- });
2694
- }
2385
+ await requireScreenCISecret(configPath);
2695
2386
  validateArgs(additionalArgs);
2696
2387
  const screenciDir = resolve(dirname(configPath), '.screenci');
2697
2388
  clearRecordingDirectories(screenciDir);