screenci 0.0.63 → 0.0.64

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) => {
@@ -1135,14 +1212,7 @@ async function writeGitHubProjectOutput(projectUrl) {
1135
1212
  await appendFile(githubOutput, `screenci_project_url=${projectUrl}\n`);
1136
1213
  }
1137
1214
  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
- }
1215
+ const resolvedConfigPath = resolveScreenCIConfigPathOrExit(configPath);
1146
1216
  let screenciConfig;
1147
1217
  try {
1148
1218
  screenciConfig =
@@ -1334,11 +1404,16 @@ async function loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath) {
1334
1404
  throw err;
1335
1405
  }
1336
1406
  }
1337
- async function requireScreenCISecret(configPath) {
1407
+ async function requireScreenCISecret(configPath, opts = {}) {
1338
1408
  const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
1339
1409
  const secret = process.env.SCREENCI_SECRET ??
1340
- (await ensureScreenciSecret(resolvedConfigPath));
1410
+ (await ensureScreenciSecret(resolvedConfigPath, opts));
1341
1411
  if (!secret) {
1412
+ // In a non-interactive session ensureScreenciSecret already printed the
1413
+ // sign-in link and the next step, so we exit without repeating guidance.
1414
+ if (opts.interactive === false) {
1415
+ process.exit(1);
1416
+ }
1342
1417
  const envFilePath = await resolveProjectEnvFilePath(resolvedConfigPath);
1343
1418
  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
1419
  process.exit(1);
@@ -1351,7 +1426,7 @@ async function requireScreenCISecret(configPath) {
1351
1426
  };
1352
1427
  }
1353
1428
  async function fetchProjectInfo(configPath) {
1354
- const { screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath);
1429
+ const { screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath, { interactive: detectInteractiveSession() });
1355
1430
  const url = new URL(`${apiUrl}/cli/project-info`);
1356
1431
  url.searchParams.set('projectName', screenciConfig.projectName);
1357
1432
  const res = await fetch(url.toString(), {
@@ -1370,7 +1445,9 @@ async function printProjectInfo(configPath) {
1370
1445
  process.stdout.write(`${JSON.stringify(info, null, 2)}\n`);
1371
1446
  }
1372
1447
  async function updateVideoVisibility(videoId, isPublic, configPath) {
1373
- const { secret, apiUrl } = await requireScreenCISecret(configPath);
1448
+ const { secret, apiUrl } = await requireScreenCISecret(configPath, {
1449
+ interactive: detectInteractiveSession(),
1450
+ });
1374
1451
  const method = isPublic ? 'PUT' : 'DELETE';
1375
1452
  const res = await fetch(`${apiUrl}/cli/public-video/${videoId}`, {
1376
1453
  method,
@@ -1461,24 +1538,34 @@ async function createLinkSessionSpec(options) {
1461
1538
  envFilePath: options.envFilePath,
1462
1539
  };
1463
1540
  }
1541
+ async function pollLinkSessionOnce(spec) {
1542
+ const response = await fetch(spec.pollUrl);
1543
+ const body = (await response.json());
1544
+ const status = body.status ?? 'invalid';
1545
+ if (status === 'completed' && body.secret) {
1546
+ return { status, secret: body.secret };
1547
+ }
1548
+ if (status === 'pending' && new Date().toISOString() >= spec.expiresAt) {
1549
+ return { status: 'expired' };
1550
+ }
1551
+ return { status };
1552
+ }
1464
1553
  async function pollLinkSession(spec) {
1465
1554
  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 };
1555
+ const result = await pollLinkSessionOnce(spec);
1556
+ if (result.status === 'completed' && result.secret) {
1557
+ return result;
1474
1558
  }
1475
- if (new Date().toISOString() >= spec.expiresAt) {
1476
- return { status: 'expired' };
1559
+ if (result.status === 'expired' ||
1560
+ result.status === 'consumed' ||
1561
+ result.status === 'invalid') {
1562
+ return result;
1477
1563
  }
1478
1564
  await new Promise((resolveDelay) => setTimeout(resolveDelay, SCREENCI_LINK_SESSION_POLL_INTERVAL_MS));
1479
1565
  }
1480
1566
  }
1481
- export async function ensureScreenciSecret(resolvedConfigPath) {
1567
+ export async function ensureScreenciSecret(resolvedConfigPath, opts = {}) {
1568
+ const interactive = opts.interactive ?? true;
1482
1569
  const existingSecret = process.env.SCREENCI_SECRET;
1483
1570
  if (existingSecret)
1484
1571
  return existingSecret;
@@ -1498,7 +1585,7 @@ export async function ensureScreenciSecret(resolvedConfigPath) {
1498
1585
  envFilePath,
1499
1586
  ...(resolvedConfigPath ? { resolvedConfigPath } : {}),
1500
1587
  };
1501
- for (;;) {
1588
+ const ensureSpec = async () => {
1502
1589
  const storedSpec = await readPersistedLinkSessionSpec(specPath);
1503
1590
  const spec = storedSpec &&
1504
1591
  isStoredLinkSessionReusable(storedSpec, linkSessionContext)
@@ -1511,14 +1598,44 @@ export async function ensureScreenciSecret(resolvedConfigPath) {
1511
1598
  if (spec !== storedSpec) {
1512
1599
  await writePersistedLinkSessionSpec(specPath, spec);
1513
1600
  }
1601
+ return spec;
1602
+ };
1603
+ const saveCompletedSecret = async (secret) => {
1604
+ process.env.SCREENCI_SECRET = secret;
1605
+ await persistScreenCISecret(envFilePath, secret);
1606
+ deletePersistedLinkSessionSpec(specPath);
1607
+ logger.info(`Successfully saved SCREENCI_SECRET to ${envFilePath}`);
1608
+ return secret;
1609
+ };
1610
+ if (!interactive) {
1611
+ // Non-interactive sessions cannot complete a browser sign-in here, so we
1612
+ // never block. Reuse or create the persisted session and check its status
1613
+ // once; if a stored session is stale, recreate it and check once more.
1614
+ // When the session is already completed (the sign-in happened between
1615
+ // runs) we pick up the secret; otherwise we print the link and return so
1616
+ // the caller can surface it and rerun later.
1617
+ let spec = await ensureSpec();
1618
+ let result = await pollLinkSessionOnce(spec);
1619
+ if (result.status === 'expired' ||
1620
+ result.status === 'consumed' ||
1621
+ result.status === 'invalid') {
1622
+ deletePersistedLinkSessionSpec(specPath);
1623
+ spec = await ensureSpec();
1624
+ result = await pollLinkSessionOnce(spec);
1625
+ }
1626
+ if (result.status === 'completed' && result.secret) {
1627
+ return await saveCompletedSecret(result.secret);
1628
+ }
1629
+ logger.info(`Sign-in required to record. Open this link to sign in and choose a plan:\n${pc.cyan(spec.appUrl)}\n` +
1630
+ `This session is non-interactive, so sign-in can't complete here. After signing in, rerun ${pc.cyan(getSuggestedScreenciCommand('record'))} to continue.`);
1631
+ return undefined;
1632
+ }
1633
+ for (;;) {
1634
+ const spec = await ensureSpec();
1514
1635
  logger.info(`Open this link to sign in and connect the CLI:\n${pc.cyan(spec.appUrl)}\n`);
1515
1636
  const result = await pollLinkSession(spec);
1516
1637
  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;
1638
+ return await saveCompletedSecret(result.secret);
1522
1639
  }
1523
1640
  deletePersistedLinkSessionSpec(specPath);
1524
1641
  }
@@ -1565,9 +1682,12 @@ export async function main() {
1565
1682
  }
1566
1683
  if (process.env.SCREENCI_RECORDING === 'true')
1567
1684
  return;
1568
- // After recording, upload results to API if configured
1569
- const resolvedConfigPath = findScreenCIConfig(parsed.configPath);
1570
- if (resolvedConfigPath) {
1685
+ // After recording, upload results to API if configured. `run` already
1686
+ // resolved the config (or exited), so this best-effort lookup only acts
1687
+ // when a flat config is present in/under the current directory.
1688
+ const resolution = findScreenCIConfig(parsed.configPath);
1689
+ if (resolution.kind === 'found') {
1690
+ const resolvedConfigPath = resolution.path;
1571
1691
  try {
1572
1692
  const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
1573
1693
  loadEnvFile(screenciConfig.envFile
@@ -1674,8 +1794,11 @@ export async function main() {
1674
1794
  .action(async () => {
1675
1795
  const parsed = parseConfigCliArgs(getSubcommandArgv('test'));
1676
1796
  let configMockRecord = false;
1677
- const resolvedConfigPath = findScreenCIConfig(parsed.configPath);
1678
- if (resolvedConfigPath) {
1797
+ // Best-effort env preload before handing off to `run`, which performs the
1798
+ // authoritative resolution (and emits the `cd screenci` guidance on miss).
1799
+ const resolution = findScreenCIConfig(parsed.configPath);
1800
+ if (resolution.kind === 'found') {
1801
+ const resolvedConfigPath = resolution.path;
1679
1802
  try {
1680
1803
  const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
1681
1804
  configMockRecord = screenciConfig.test?.mockRecord ?? false;
@@ -1846,21 +1969,16 @@ function validateArgs(args) {
1846
1969
  }
1847
1970
  }
1848
1971
  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
- }
1972
+ const configPath = resolveScreenCIConfigPathOrExit(customConfigPath);
1857
1973
  if (command === 'test' || process.env.SCREENCI_RECORDING !== 'true') {
1858
1974
  await loadEnvFileFromConfigSource(configPath, false);
1859
1975
  }
1860
1976
  // Only validate args for record command
1861
1977
  if (command === 'record') {
1862
1978
  if (!process.env.SCREENCI_SECRET) {
1863
- await requireScreenCISecret(configPath);
1979
+ await requireScreenCISecret(configPath, {
1980
+ interactive: detectInteractiveSession(),
1981
+ });
1864
1982
  }
1865
1983
  validateArgs(additionalArgs);
1866
1984
  const screenciDir = resolve(dirname(configPath), '.screenci');