screenci 0.0.63 → 0.0.65

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 CHANGED
@@ -15,23 +15,27 @@ npm init screenci@latest
15
15
  pnpm create screenci
16
16
  ```
17
17
 
18
- `init` creates a ready-to-run project in the current directory, installs
19
- dependencies, and installs Chromium by default. When using `npm init`, pass
18
+ `init` creates a ready-to-run, self-contained `screenci/` directory ("the
19
+ island") with its own dependencies, and installs Chromium by default. The
20
+ island is deliberately isolated from any surrounding workspace, which makes
21
+ installation reliable inside complex monorepos. When using `npm init`, pass
20
22
  extra initializer flags after `--`, for example
21
23
  `npm init screenci@latest -- --yes --package-manager pnpm`.
22
24
 
23
25
  ```text
24
- screenci.config.ts
25
- package.json
26
- tsconfig.json
27
- README.md
28
- .gitignore
29
- .env
30
- videos/
31
- example.video.ts
32
- .github/workflows/screenci.yaml
26
+ screenci/
27
+ screenci.config.ts
28
+ package.json
29
+ package-lock.json # or pnpm-lock.yaml / yarn.lock
30
+ .gitignore
31
+ videos/
32
+ example.video.ts
33
+ .github/workflows/screenci.yaml # at the repo root, scoped to screenci/
33
34
  ```
34
35
 
36
+ `screenci test` and `screenci record` work both from inside `screenci/` and
37
+ from the repository root.
38
+
35
39
  Docs:
36
40
 
37
41
  - Getting started: `https://screenci.com/docs`
@@ -92,12 +96,16 @@ Playwright without starting the final recording and upload path.
92
96
  npx screenci record
93
97
  ```
94
98
 
95
- On the first run without `SCREENCI_SECRET`, `record` prints a one-time ScreenCI
96
- link, waits for you to finish sign-in in the browser, saves the secret into the
97
- project env file, and then continues. Pending auth state is cached in
98
- `.screenci/link-session.json`, so rerunning `record` reuses the same link until
99
- it expires or completes. Recorded artifacts still live in
100
- `.screenci/<video-name>/`.
99
+ On the first run without `SCREENCI_SECRET` in an interactive terminal, `record`
100
+ prints a one-time ScreenCI link, waits for you to finish sign-in in the browser,
101
+ saves the secret into the project env file, and then continues. In a
102
+ non-interactive session (no terminal, or `SCREENCI_NONINTERACTIVE=1`) `record`
103
+ does not wait: it prints the sign-in link and exits, so you can open the link,
104
+ sign in, choose a plan, and rerun `record`, which then detects the completed
105
+ session and continues. Set `SCREENCI_SECRET` ahead of time to skip sign-in
106
+ entirely. Pending auth state is cached in `.screenci/link-session.json`, so
107
+ rerunning `record` reuses the same link until it expires or completes. Recorded
108
+ artifacts still live in `.screenci/<video-name>/`.
101
109
 
102
110
  ## Configure
103
111
 
package/dist/cli.d.ts CHANGED
@@ -8,6 +8,20 @@ type PlaywrightListReportSuite = {
8
8
  }>;
9
9
  suites?: PlaywrightListReportSuite[];
10
10
  };
11
+ /**
12
+ * Reports whether the current session can complete an interactive browser
13
+ * sign-in. A session is interactive only when both stdin and stdout are
14
+ * attached to a terminal and no signal marks the run as automated. This is the
15
+ * proxy for "a human is present to open the sign-in link" — it does not attempt
16
+ * to identify any particular caller (CI, a piped shell, or an automated tool).
17
+ *
18
+ * Dependency-injected so tests can force a value without a real terminal.
19
+ */
20
+ export declare function detectInteractiveSession(env?: NodeJS.ProcessEnv, stdout?: {
21
+ isTTY?: boolean;
22
+ }, stdin?: {
23
+ isTTY?: boolean;
24
+ }): boolean;
11
25
  export declare function collectPlaywrightListTitles(suites: readonly PlaywrightListReportSuite[]): string[];
12
26
  type PreparedUploadAsset = {
13
27
  fileHash: string;
@@ -66,7 +80,9 @@ export declare function extractConfigStringLiteral(configSource: string, propert
66
80
  export declare function extractRecordUploadPolicyLiteral(configSource: string): RecordUploadPolicy | undefined;
67
81
  export declare function extractMockRecordLiteral(configSource: string): boolean | undefined;
68
82
  export declare function getConfigModuleSpecifier(resolvedConfigPath: string): string;
69
- export declare function ensureScreenciSecret(resolvedConfigPath?: string): Promise<string | undefined>;
83
+ export declare function ensureScreenciSecret(resolvedConfigPath?: string, opts?: {
84
+ interactive?: boolean;
85
+ }): Promise<string | undefined>;
70
86
  export declare function main(): Promise<void>;
71
87
  export declare function logCliError(error: unknown): void;
72
88
  export {};
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
@@ -36,6 +36,22 @@ function getScreenCIEnvironment() {
36
36
  const parsed = parseScreenCIEnvironment(process.env[SCREENCI_ENVIRONMENT_VARIABLE]);
37
37
  return parsed ?? 'prod';
38
38
  }
39
+ /**
40
+ * Reports whether the current session can complete an interactive browser
41
+ * sign-in. A session is interactive only when both stdin and stdout are
42
+ * attached to a terminal and no signal marks the run as automated. This is the
43
+ * proxy for "a human is present to open the sign-in link" — it does not attempt
44
+ * to identify any particular caller (CI, a piped shell, or an automated tool).
45
+ *
46
+ * Dependency-injected so tests can force a value without a real terminal.
47
+ */
48
+ export function detectInteractiveSession(env = process.env, stdout = process.stdout, stdin = process.stdin) {
49
+ if (env.SCREENCI_NONINTERACTIVE === '1')
50
+ return false;
51
+ if (env.CI === 'true')
52
+ return false;
53
+ return Boolean(stdout.isTTY) && Boolean(stdin.isTTY);
54
+ }
39
55
  export function collectPlaywrightListTitles(suites) {
40
56
  const titles = [];
41
57
  const visitSuite = (suite) => {
@@ -575,10 +591,27 @@ function resolveWindowsCmdShim(cmd) {
575
591
  }
576
592
  return shimName;
577
593
  }
594
+ function isModuleNotFoundError(error) {
595
+ return (error instanceof Error &&
596
+ error.code === 'MODULE_NOT_FOUND');
597
+ }
598
+ function resolvePlaywrightCliEntrypoint(searchFrom) {
599
+ // Prefer the @playwright/test installed alongside the user's config, since it
600
+ // is declared as a peer dependency of the project being recorded.
601
+ try {
602
+ return require.resolve('@playwright/test/cli', { paths: [searchFrom] });
603
+ }
604
+ catch (error) {
605
+ if (!isModuleNotFoundError(error))
606
+ throw error;
607
+ }
608
+ // Fall back to the copy resolvable from the screenci CLI's own install. This
609
+ // keeps discovery working when Playwright is hoisted to a parent install or
610
+ // bundled with the CLI rather than next to the config file.
611
+ return require.resolve('@playwright/test/cli');
612
+ }
578
613
  function resolvePlaywrightSpawnSpec(args, searchFrom) {
579
- const cliEntrypoint = require.resolve('@playwright/test/cli', {
580
- paths: [searchFrom],
581
- });
614
+ const cliEntrypoint = resolvePlaywrightCliEntrypoint(searchFrom);
582
615
  return {
583
616
  command: process.execPath,
584
617
  args: [cliEntrypoint, ...args],
@@ -662,17 +695,61 @@ function clearRecordingDirectories(dir) {
662
695
  function findScreenCIConfig(customPath) {
663
696
  if (customPath) {
664
697
  const resolvedPath = resolve(process.cwd(), customPath);
665
- if (existsSync(resolvedPath)) {
666
- return resolvedPath;
698
+ return existsSync(resolvedPath)
699
+ ? { kind: 'found', path: resolvedPath }
700
+ : { kind: 'not-found' };
701
+ }
702
+ // Walk up from the current directory looking for a flat `screenci.config.ts`,
703
+ // which is what's present when the command runs from inside the `screenci/`
704
+ // island. We deliberately do NOT auto-use a nested
705
+ // `screenci/screenci.config.ts`: running the CLI from outside the island
706
+ // resolves the `screenci` binary from the registry (npx download) rather than
707
+ // the version-pinned island install, so it would silently run a different
708
+ // version. Instead we detect the island and ask the user to `cd` into it.
709
+ let current = process.cwd();
710
+ let islandConfigPath;
711
+ while (true) {
712
+ const flatConfig = resolve(current, 'screenci.config.ts');
713
+ if (existsSync(flatConfig)) {
714
+ return { kind: 'found', path: flatConfig };
715
+ }
716
+ if (islandConfigPath === undefined) {
717
+ const islandConfig = resolve(current, 'screenci', 'screenci.config.ts');
718
+ if (existsSync(islandConfig)) {
719
+ islandConfigPath = islandConfig;
720
+ }
667
721
  }
668
- return null;
722
+ const parent = dirname(current);
723
+ if (parent === current)
724
+ break;
725
+ current = parent;
669
726
  }
670
- const cwd = process.cwd();
671
- const configPath = resolve(cwd, 'screenci.config.ts');
672
- if (existsSync(configPath)) {
673
- return configPath;
727
+ if (islandConfigPath !== undefined) {
728
+ return { kind: 'island-not-entered', islandConfigPath };
729
+ }
730
+ return { kind: 'not-found' };
731
+ }
732
+ // Resolve the config path, or log a helpful message and exit. Centralizes the
733
+ // `cd screenci` guidance so every command (test/record/info/...) behaves the
734
+ // same when invoked from outside the island.
735
+ function resolveScreenCIConfigPathOrExit(customPath) {
736
+ const resolution = findScreenCIConfig(customPath);
737
+ switch (resolution.kind) {
738
+ case 'found':
739
+ return resolution.path;
740
+ case 'island-not-entered': {
741
+ const islandDir = dirname(resolution.islandConfigPath);
742
+ const relDir = pathRelative(process.cwd(), islandDir) || '.';
743
+ logger.error(`Error: no screenci.config.ts found here, but found ${pc.cyan(`${relDir}/screenci.config.ts`)}. Run ${pc.cyan(`cd ${relDir}`)} and rerun the command from there.`);
744
+ return process.exit(1);
745
+ }
746
+ case 'not-found': {
747
+ logger.error(customPath
748
+ ? `Error: Config file not found: ${customPath}`
749
+ : 'Error: screenci.config.ts not found in the current directory or any parent.');
750
+ return process.exit(1);
751
+ }
674
752
  }
675
- return null;
676
753
  }
677
754
  async function hashFile(filePath) {
678
755
  return new Promise((resolveHash, reject) => {
@@ -1123,7 +1200,13 @@ export function getDevFrontendUrl() {
1123
1200
  }
1124
1201
  }
1125
1202
  export function getCliLinkSessionApiUrl() {
1126
- return getDevFrontendUrl();
1203
+ // The `/cli-link/session` routes are Convex HTTP actions served from the same
1204
+ // backend host as the `/cli/*` upload endpoints. We hit that host directly
1205
+ // rather than the frontend: the frontend only forwards these routes via the
1206
+ // vite dev-server proxy, which does not exist on the hosted dev/prod frontend
1207
+ // (a POST there returns 405). The CLI runs under Node, so there is no CORS
1208
+ // constraint that would require going through the frontend origin.
1209
+ return getDevBackendUrl();
1127
1210
  }
1128
1211
  function getScreenCISecretsUrl() {
1129
1212
  return `${getDevFrontendUrl()}/secrets`;
@@ -1135,14 +1218,7 @@ async function writeGitHubProjectOutput(projectUrl) {
1135
1218
  await appendFile(githubOutput, `screenci_project_url=${projectUrl}\n`);
1136
1219
  }
1137
1220
  async function loadScreenCIConfigAndEnv(configPath) {
1138
- const resolvedConfigPath = findScreenCIConfig(configPath);
1139
- if (!resolvedConfigPath) {
1140
- const errorMsg = configPath
1141
- ? `Error: Config file not found: ${configPath}`
1142
- : 'Error: screenci.config.ts not found in current directory';
1143
- logger.error(errorMsg);
1144
- process.exit(1);
1145
- }
1221
+ const resolvedConfigPath = resolveScreenCIConfigPathOrExit(configPath);
1146
1222
  let screenciConfig;
1147
1223
  try {
1148
1224
  screenciConfig =
@@ -1334,11 +1410,16 @@ async function loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath) {
1334
1410
  throw err;
1335
1411
  }
1336
1412
  }
1337
- async function requireScreenCISecret(configPath) {
1413
+ async function requireScreenCISecret(configPath, opts = {}) {
1338
1414
  const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
1339
1415
  const secret = process.env.SCREENCI_SECRET ??
1340
- (await ensureScreenciSecret(resolvedConfigPath));
1416
+ (await ensureScreenciSecret(resolvedConfigPath, opts));
1341
1417
  if (!secret) {
1418
+ // In a non-interactive session ensureScreenciSecret already printed the
1419
+ // sign-in link and the next step, so we exit without repeating guidance.
1420
+ if (opts.interactive === false) {
1421
+ process.exit(1);
1422
+ }
1342
1423
  const envFilePath = await resolveProjectEnvFilePath(resolvedConfigPath);
1343
1424
  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)}.`);
1344
1425
  process.exit(1);
@@ -1351,7 +1432,7 @@ async function requireScreenCISecret(configPath) {
1351
1432
  };
1352
1433
  }
1353
1434
  async function fetchProjectInfo(configPath) {
1354
- const { screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath);
1435
+ const { screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath, { interactive: detectInteractiveSession() });
1355
1436
  const url = new URL(`${apiUrl}/cli/project-info`);
1356
1437
  url.searchParams.set('projectName', screenciConfig.projectName);
1357
1438
  const res = await fetch(url.toString(), {
@@ -1370,7 +1451,9 @@ async function printProjectInfo(configPath) {
1370
1451
  process.stdout.write(`${JSON.stringify(info, null, 2)}\n`);
1371
1452
  }
1372
1453
  async function updateVideoVisibility(videoId, isPublic, configPath) {
1373
- const { secret, apiUrl } = await requireScreenCISecret(configPath);
1454
+ const { secret, apiUrl } = await requireScreenCISecret(configPath, {
1455
+ interactive: detectInteractiveSession(),
1456
+ });
1374
1457
  const method = isPublic ? 'PUT' : 'DELETE';
1375
1458
  const res = await fetch(`${apiUrl}/cli/public-video/${videoId}`, {
1376
1459
  method,
@@ -1461,24 +1544,34 @@ async function createLinkSessionSpec(options) {
1461
1544
  envFilePath: options.envFilePath,
1462
1545
  };
1463
1546
  }
1547
+ async function pollLinkSessionOnce(spec) {
1548
+ const response = await fetch(spec.pollUrl);
1549
+ const body = (await response.json());
1550
+ const status = body.status ?? 'invalid';
1551
+ if (status === 'completed' && body.secret) {
1552
+ return { status, secret: body.secret };
1553
+ }
1554
+ if (status === 'pending' && new Date().toISOString() >= spec.expiresAt) {
1555
+ return { status: 'expired' };
1556
+ }
1557
+ return { status };
1558
+ }
1464
1559
  async function pollLinkSession(spec) {
1465
1560
  for (;;) {
1466
- const response = await fetch(spec.pollUrl);
1467
- const body = (await response.json());
1468
- const status = body.status ?? 'invalid';
1469
- if (status === 'completed' && body.secret) {
1470
- return { status, secret: body.secret };
1471
- }
1472
- if (status === 'expired' || status === 'consumed' || status === 'invalid') {
1473
- return { status };
1561
+ const result = await pollLinkSessionOnce(spec);
1562
+ if (result.status === 'completed' && result.secret) {
1563
+ return result;
1474
1564
  }
1475
- if (new Date().toISOString() >= spec.expiresAt) {
1476
- return { status: 'expired' };
1565
+ if (result.status === 'expired' ||
1566
+ result.status === 'consumed' ||
1567
+ result.status === 'invalid') {
1568
+ return result;
1477
1569
  }
1478
1570
  await new Promise((resolveDelay) => setTimeout(resolveDelay, SCREENCI_LINK_SESSION_POLL_INTERVAL_MS));
1479
1571
  }
1480
1572
  }
1481
- export async function ensureScreenciSecret(resolvedConfigPath) {
1573
+ export async function ensureScreenciSecret(resolvedConfigPath, opts = {}) {
1574
+ const interactive = opts.interactive ?? true;
1482
1575
  const existingSecret = process.env.SCREENCI_SECRET;
1483
1576
  if (existingSecret)
1484
1577
  return existingSecret;
@@ -1498,7 +1591,7 @@ export async function ensureScreenciSecret(resolvedConfigPath) {
1498
1591
  envFilePath,
1499
1592
  ...(resolvedConfigPath ? { resolvedConfigPath } : {}),
1500
1593
  };
1501
- for (;;) {
1594
+ const ensureSpec = async () => {
1502
1595
  const storedSpec = await readPersistedLinkSessionSpec(specPath);
1503
1596
  const spec = storedSpec &&
1504
1597
  isStoredLinkSessionReusable(storedSpec, linkSessionContext)
@@ -1511,14 +1604,44 @@ export async function ensureScreenciSecret(resolvedConfigPath) {
1511
1604
  if (spec !== storedSpec) {
1512
1605
  await writePersistedLinkSessionSpec(specPath, spec);
1513
1606
  }
1607
+ return spec;
1608
+ };
1609
+ const saveCompletedSecret = async (secret) => {
1610
+ process.env.SCREENCI_SECRET = secret;
1611
+ await persistScreenCISecret(envFilePath, secret);
1612
+ deletePersistedLinkSessionSpec(specPath);
1613
+ logger.info(`Successfully saved SCREENCI_SECRET to ${envFilePath}`);
1614
+ return secret;
1615
+ };
1616
+ 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.
1623
+ let spec = await ensureSpec();
1624
+ let result = await pollLinkSessionOnce(spec);
1625
+ if (result.status === 'expired' ||
1626
+ result.status === 'consumed' ||
1627
+ result.status === 'invalid') {
1628
+ deletePersistedLinkSessionSpec(specPath);
1629
+ spec = await ensureSpec();
1630
+ result = await pollLinkSessionOnce(spec);
1631
+ }
1632
+ if (result.status === 'completed' && result.secret) {
1633
+ return await saveCompletedSecret(result.secret);
1634
+ }
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.`);
1637
+ return undefined;
1638
+ }
1639
+ for (;;) {
1640
+ const spec = await ensureSpec();
1514
1641
  logger.info(`Open this link to sign in and connect the CLI:\n${pc.cyan(spec.appUrl)}\n`);
1515
1642
  const result = await pollLinkSession(spec);
1516
1643
  if (result.status === 'completed' && result.secret) {
1517
- process.env.SCREENCI_SECRET = result.secret;
1518
- await persistScreenCISecret(envFilePath, result.secret);
1519
- deletePersistedLinkSessionSpec(specPath);
1520
- logger.info(`Successfully saved SCREENCI_SECRET to ${envFilePath}`);
1521
- return result.secret;
1644
+ return await saveCompletedSecret(result.secret);
1522
1645
  }
1523
1646
  deletePersistedLinkSessionSpec(specPath);
1524
1647
  }
@@ -1565,9 +1688,12 @@ export async function main() {
1565
1688
  }
1566
1689
  if (process.env.SCREENCI_RECORDING === 'true')
1567
1690
  return;
1568
- // After recording, upload results to API if configured
1569
- const resolvedConfigPath = findScreenCIConfig(parsed.configPath);
1570
- if (resolvedConfigPath) {
1691
+ // After recording, upload results to API if configured. `run` already
1692
+ // resolved the config (or exited), so this best-effort lookup only acts
1693
+ // when a flat config is present in/under the current directory.
1694
+ const resolution = findScreenCIConfig(parsed.configPath);
1695
+ if (resolution.kind === 'found') {
1696
+ const resolvedConfigPath = resolution.path;
1571
1697
  try {
1572
1698
  const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
1573
1699
  loadEnvFile(screenciConfig.envFile
@@ -1674,8 +1800,11 @@ export async function main() {
1674
1800
  .action(async () => {
1675
1801
  const parsed = parseConfigCliArgs(getSubcommandArgv('test'));
1676
1802
  let configMockRecord = false;
1677
- const resolvedConfigPath = findScreenCIConfig(parsed.configPath);
1678
- if (resolvedConfigPath) {
1803
+ // Best-effort env preload before handing off to `run`, which performs the
1804
+ // authoritative resolution (and emits the `cd screenci` guidance on miss).
1805
+ const resolution = findScreenCIConfig(parsed.configPath);
1806
+ if (resolution.kind === 'found') {
1807
+ const resolvedConfigPath = resolution.path;
1679
1808
  try {
1680
1809
  const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
1681
1810
  configMockRecord = screenciConfig.test?.mockRecord ?? false;
@@ -1846,21 +1975,16 @@ function validateArgs(args) {
1846
1975
  }
1847
1976
  }
1848
1977
  async function run(command, additionalArgs, customConfigPath, verbose = false, mockRecord = false) {
1849
- const configPath = findScreenCIConfig(customConfigPath);
1850
- if (!configPath) {
1851
- const errorMsg = customConfigPath
1852
- ? `Error: Config file not found: ${customConfigPath}`
1853
- : 'Error: screenci.config.ts not found in current directory';
1854
- logger.error(errorMsg);
1855
- process.exit(1);
1856
- }
1978
+ const configPath = resolveScreenCIConfigPathOrExit(customConfigPath);
1857
1979
  if (command === 'test' || process.env.SCREENCI_RECORDING !== 'true') {
1858
1980
  await loadEnvFileFromConfigSource(configPath, false);
1859
1981
  }
1860
1982
  // Only validate args for record command
1861
1983
  if (command === 'record') {
1862
1984
  if (!process.env.SCREENCI_SECRET) {
1863
- await requireScreenCISecret(configPath);
1985
+ await requireScreenCISecret(configPath, {
1986
+ interactive: detectInteractiveSession(),
1987
+ });
1864
1988
  }
1865
1989
  validateArgs(additionalArgs);
1866
1990
  const screenciDir = resolve(dirname(configPath), '.screenci');