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/README.md +12 -12
- package/dist/cli.d.ts +14 -21
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +58 -367
- package/dist/cli.js.map +1 -1
- package/dist/docs/video-sources/installation.screenci.js +2 -10
- package/dist/docs/video-sources/installation.screenci.js.map +1 -1
- package/dist/src/init.d.ts +10 -10
- package/dist/src/init.d.ts.map +1 -1
- package/dist/src/init.js +73 -60
- package/dist/src/init.js.map +1 -1
- package/dist/src/linkSession.d.ts +25 -28
- package/dist/src/linkSession.d.ts.map +1 -1
- package/dist/src/linkSession.js +74 -58
- package/dist/src/linkSession.js.map +1 -1
- package/dist/src/localize.d.ts +3 -2
- package/dist/src/localize.d.ts.map +1 -1
- package/dist/src/localize.js +3 -3
- package/dist/src/localize.js.map +1 -1
- package/dist/src/video.d.ts +7 -9
- package/dist/src/video.d.ts.map +1 -1
- package/dist/src/video.js +5 -7
- package/dist/src/video.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/skills/screenci/SKILL.md +32 -41
- package/skills/screenci/references/init.md +7 -7
- package/skills/screenci/references/record.md +8 -7
- package/dist/src/openBrowser.d.ts +0 -34
- package/dist/src/openBrowser.d.ts.map +0 -1
- package/dist/src/openBrowser.js +0 -62
- package/dist/src/openBrowser.js.map +0 -1
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 {
|
|
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
|
-
//
|
|
471
|
-
// (
|
|
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) =>
|
|
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
|
|
1759
|
+
export async function requireScreenCISecret(configPath) {
|
|
1764
1760
|
const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
|
|
1765
|
-
|
|
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
|
-
//
|
|
1783
|
-
//
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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);
|