screenci 0.0.45 → 0.0.46

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
@@ -3,14 +3,13 @@ import { createReadStream } from 'fs';
3
3
  import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync } from 'fs';
4
4
  import { createHash } from 'crypto';
5
5
  import { createServer } from 'http';
6
- import { appendFile, mkdir, readdir, readFile, stat, writeFile, } from 'fs/promises';
7
- import { basename, dirname, relative as pathRelative, resolve } from 'path';
6
+ import { appendFile, readdir, readFile, stat } from 'fs/promises';
7
+ import { delimiter, dirname, relative as pathRelative, resolve } from 'path';
8
8
  import { fileURLToPath, pathToFileURL } from 'url';
9
9
  import { Command, CommanderError } from 'commander';
10
- import { input } from '@inquirer/prompts';
11
- import ora from 'ora';
12
10
  import pc from 'picocolors';
13
11
  import { logger } from './src/logger.js';
12
+ import { determinePackageManager, parsePackageManager, runInit, } from './src/init.js';
14
13
  import { SCREENCI_DISABLE_RECORDING_TIMINGS_ENV, SCREENCI_MOCK_RECORD_ENV, } from './src/runtimeMode.js';
15
14
  import { DEFAULT_RECORD_UPLOAD_POLICY } from './src/defaults.js';
16
15
  import { findDuplicateTitles, formatDuplicateTitlesMessage, } from './src/titleValidation.js';
@@ -47,6 +46,11 @@ async function collectDiscoveredTestTitles(configPath, additionalArgs, env) {
47
46
  const child = spawn(spawnSpec.command, spawnSpec.args, {
48
47
  stdio: ['inherit', 'pipe', 'pipe'],
49
48
  ...(spawnSpec.shell !== undefined ? { shell: spawnSpec.shell } : {}),
49
+ ...(spawnSpec.windowsVerbatimArguments !== undefined
50
+ ? {
51
+ windowsVerbatimArguments: spawnSpec.windowsVerbatimArguments,
52
+ }
53
+ : {}),
50
54
  env,
51
55
  });
52
56
  const childSignals = forwardChildSignals(child, 'screenci title validation', {
@@ -189,6 +193,9 @@ function createUploadProgressReporter(videoNames, verbose) {
189
193
  complete(index, status) {
190
194
  logger.info(formatUploadProgressLine(videoNames[index] ?? 'unknown', status));
191
195
  },
196
+ info(message) {
197
+ logger.info(message);
198
+ },
192
199
  };
193
200
  }
194
201
  const statuses = new Array(videoNames.length);
@@ -198,12 +205,23 @@ function createUploadProgressReporter(videoNames, verbose) {
198
205
  process.stdout.write(`${hasRendered && videoNames.length > 0 ? `\u001B[${videoNames.length}A` : ''}${renderedLines.map((line) => `\r\u001B[2K${line}`).join('\n')}\n`);
199
206
  hasRendered = true;
200
207
  };
208
+ const clear = () => {
209
+ if (!hasRendered || videoNames.length === 0)
210
+ return;
211
+ process.stdout.write(`\u001B[${videoNames.length}A${Array.from({ length: videoNames.length }, () => '\r\u001B[2K').join('\n')}\n`);
212
+ hasRendered = false;
213
+ };
201
214
  render();
202
215
  return {
203
216
  complete(index, status) {
204
217
  statuses[index] = status;
205
218
  render();
206
219
  },
220
+ info(message) {
221
+ clear();
222
+ logger.info(message);
223
+ render();
224
+ },
207
225
  };
208
226
  }
209
227
  async function loadUploadCandidate(screenciDir, entry, verbose) {
@@ -277,7 +295,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
277
295
  logger.info(`recordingId=${recordingId} projectId=${projectId}`);
278
296
  logger.info(`assets=${preparedUploadAssets.length} recordingHash=${recordingHash ?? 'none'}`);
279
297
  }
280
- await uploadAssets(preparedUploadAssets, apiUrl, secret, recordingId, uploadAbort.signal, uploadAbort.throwIfAborted);
298
+ await uploadAssets(preparedUploadAssets, apiUrl, secret, recordingId, uploadAbort.signal, uploadAbort.throwIfAborted, progressReporter);
281
299
  if (existsSync(recordingPath)) {
282
300
  uploadAbort.throwIfAborted();
283
301
  const fileStat = await stat(recordingPath);
@@ -387,59 +405,53 @@ function createUploadAbortController(activityLabel) {
387
405
  cleanup,
388
406
  };
389
407
  }
390
- function quoteWindowsCommandArg(arg) {
391
- if (arg.length === 0)
392
- return '""';
393
- if (/^[A-Za-z0-9_./:\\=-]+$/.test(arg))
394
- return arg;
395
- return `"${arg.replace(/"/g, '""')}"`;
396
- }
397
408
  function resolveSpawnSpec(cmd, args) {
398
409
  if (process.platform !== 'win32') {
399
410
  return { command: cmd, args };
400
411
  }
401
- const windowsCmdsNeedingShell = new Set(['npm', 'npx', 'playwright']);
402
- if (!windowsCmdsNeedingShell.has(cmd)) {
412
+ const windowsCmdShims = new Set(['npm', 'npx', 'playwright', 'pnpm']);
413
+ if (!windowsCmdShims.has(cmd)) {
403
414
  return { command: cmd, args };
404
415
  }
405
- const commandLine = [cmd, ...args].map(quoteWindowsCommandArg).join(' ');
406
416
  return {
407
- command: 'cmd',
408
- args: ['/d', '/c', commandLine],
417
+ command: process.env.comspec ?? 'cmd.exe',
418
+ args: ['/d', '/s', '/c', `"${buildWindowsBatchCommandLine(cmd, args)}"`],
419
+ windowsVerbatimArguments: true,
409
420
  };
410
421
  }
411
- function spawnSilent(cmd, args, cwd) {
412
- return new Promise((resolve, reject) => {
413
- const spawnSpec = resolveSpawnSpec(cmd, args);
414
- const child = spawn(spawnSpec.command, spawnSpec.args, {
415
- stdio: 'pipe',
416
- ...(spawnSpec.shell !== undefined ? { shell: spawnSpec.shell } : {}),
417
- ...(cwd ? { cwd } : {}),
418
- });
419
- const childSignals = forwardChildSignals(child, cmd);
420
- child.on('close', (code, signal) => {
421
- const forwardedSignal = childSignals.getForwardedSignal();
422
- childSignals.cleanup();
423
- if (forwardedSignal) {
424
- process.kill(process.pid, forwardedSignal);
425
- return;
426
- }
427
- if (signal) {
428
- process.kill(process.pid, signal);
429
- return;
430
- }
431
- else if (code === 0) {
432
- resolve();
433
- }
434
- else {
435
- reject(new Error(`${cmd} exited with code ${code}`));
436
- }
437
- });
438
- child.on('error', (err) => {
439
- childSignals.cleanup();
440
- reject(err);
441
- });
442
- });
422
+ function quoteWindowsBatchArg(arg) {
423
+ if (arg.length === 0) {
424
+ return '""';
425
+ }
426
+ return `"${arg
427
+ .replace(/(\\*)"/g, '$1$1\\"')
428
+ .replace(/(\\+)$/g, '$1$1')
429
+ .replace(/%/g, '%%')}"`;
430
+ }
431
+ function buildWindowsBatchCommandLine(cmd, args) {
432
+ return [resolveWindowsCmdShim(cmd), ...args]
433
+ .map(quoteWindowsBatchArg)
434
+ .join(' ');
435
+ }
436
+ function resolveWindowsCmdShim(cmd) {
437
+ const shimName = `${cmd}.cmd`;
438
+ const pathEntries = process.env.PATH?.split(delimiter) ?? [];
439
+ for (const entry of pathEntries) {
440
+ if (!entry)
441
+ continue;
442
+ const shimPath = resolve(entry, shimName);
443
+ if (existsSync(shimPath)) {
444
+ return shimPath;
445
+ }
446
+ }
447
+ const bundledShimCommands = new Set(['npm', 'npx']);
448
+ if (bundledShimCommands.has(cmd)) {
449
+ const bundledShimPath = resolve(dirname(process.execPath), shimName);
450
+ if (existsSync(bundledShimPath)) {
451
+ return bundledShimPath;
452
+ }
453
+ }
454
+ return shimName;
443
455
  }
444
456
  function forwardChildSignals(child, activityLabel, options = {}) {
445
457
  let forwardedSignal = null;
@@ -764,6 +776,16 @@ export function formatUploadStartFailureMessage(videoName, status, responseText,
764
776
  }
765
777
  return `Failed to start upload for "${videoName}": ${status}${hint401(status, secret)}`;
766
778
  }
779
+ const EXPRESSIVE_TIER_ERROR_PREFIX = 'Expressive narration and style prompts require the Business tier.';
780
+ export function formatFailedVideoMessage(videoName, message) {
781
+ if (message.startsWith(EXPRESSIVE_TIER_ERROR_PREFIX)) {
782
+ return [
783
+ `${videoName}: ${message}`,
784
+ "If you want to keep using the current tier, remove `voice.style` or `modelType: 'expressive'` from `createNarration()`.",
785
+ ].join('\n');
786
+ }
787
+ return `${videoName}: ${message}`;
788
+ }
767
789
  export function printUploadStartFailureMessage(videoName, status, responseText, secret) {
768
790
  const message = formatUploadStartFailureMessage(videoName, status, responseText, secret);
769
791
  if (responseText.trim().length > 0) {
@@ -772,7 +794,15 @@ export function printUploadStartFailureMessage(videoName, status, responseText,
772
794
  }
773
795
  logger.warn(message);
774
796
  }
775
- async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIfAborted) {
797
+ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIfAborted, progressReporter) {
798
+ const logInfo = (message) => {
799
+ if (progressReporter) {
800
+ progressReporter.info(message);
801
+ }
802
+ else {
803
+ logger.info(message);
804
+ }
805
+ };
776
806
  for (const asset of assets) {
777
807
  throwIfAborted();
778
808
  try {
@@ -798,7 +828,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
798
828
  }
799
829
  const checkBody = (await checkRes.json());
800
830
  if (checkBody.exists) {
801
- logger.info(`Asset already exists: ${asset.path}`);
831
+ logInfo(`Asset already exists: ${asset.path}`);
802
832
  continue;
803
833
  }
804
834
  if (!asset.fileBuffer || !asset.contentType) {
@@ -825,14 +855,14 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
825
855
  if (!res.ok) {
826
856
  const text = await res.text();
827
857
  if (res.status === 409 && text.includes('already exists')) {
828
- logger.info(`Asset already exists: ${asset.path}`);
858
+ logInfo(`Asset already exists: ${asset.path}`);
829
859
  }
830
860
  else {
831
861
  logger.warn(`Failed to upload asset ${asset.path}: ${res.status} ${text}${hint401(res.status, secret)}`);
832
862
  }
833
863
  }
834
864
  else {
835
- logger.info(`Asset uploaded: ${asset.path}`);
865
+ logInfo(`Asset uploaded: ${asset.path}`);
836
866
  }
837
867
  }
838
868
  catch (err) {
@@ -1011,6 +1041,12 @@ export function extractRecordUploadPolicyLiteral(configSource) {
1011
1041
  const templateLiteralMatch = configSource.match(/record\s*:\s*\{[\s\S]*?upload\s*:\s*`(passed-only|all-or-nothing)`/);
1012
1042
  return templateLiteralMatch?.[1];
1013
1043
  }
1044
+ export function extractMockRecordLiteral(configSource) {
1045
+ const match = configSource.match(/test\s*:\s*\{[\s\S]*?mockRecord\s*:\s*(true|false)/);
1046
+ if (!match)
1047
+ return undefined;
1048
+ return match[1] === 'true';
1049
+ }
1014
1050
  function resolveRecordUploadPolicy(config) {
1015
1051
  return config.record?.upload ?? DEFAULT_RECORD_UPLOAD_POLICY;
1016
1052
  }
@@ -1022,10 +1058,12 @@ async function tryReadConfigFromSource(resolvedConfigPath) {
1022
1058
  }
1023
1059
  const envFile = extractConfigStringLiteral(configSource, 'envFile');
1024
1060
  const recordUpload = extractRecordUploadPolicyLiteral(configSource);
1061
+ const mockRecord = extractMockRecordLiteral(configSource);
1025
1062
  return {
1026
1063
  projectName,
1027
1064
  ...(envFile !== undefined ? { envFile } : {}),
1028
1065
  ...(recordUpload !== undefined ? { record: { upload: recordUpload } } : {}),
1066
+ ...(mockRecord !== undefined ? { test: { mockRecord } } : {}),
1029
1067
  };
1030
1068
  }
1031
1069
  export function getConfigModuleSpecifier(resolvedConfigPath) {
@@ -1103,151 +1141,6 @@ async function updateVideoVisibility(videoId, isPublic, configPath) {
1103
1141
  }
1104
1142
  logger.info(`${isPublic ? 'Made public' : 'Made private'}: ${videoId}`);
1105
1143
  }
1106
- function generateConfig(projectName) {
1107
- return `import { defineConfig } from 'screenci'
1108
-
1109
- export default defineConfig({
1110
- projectName: ${JSON.stringify(projectName)},
1111
- envFile: '.env',
1112
- videoDir: './videos',
1113
- use: {
1114
- recordOptions: {
1115
- aspectRatio: '16:9',
1116
- quality: '1080p',
1117
- fps: 60,
1118
- },
1119
- },
1120
- projects: [
1121
- {
1122
- name: 'chromium',
1123
- },
1124
- ],
1125
- })
1126
- `;
1127
- }
1128
- function generatePackageJson(includePlaywrightCli = false, screenciDependency = 'latest') {
1129
- const devDependencies = {};
1130
- if (includePlaywrightCli) {
1131
- devDependencies['@playwright/cli'] = 'latest';
1132
- }
1133
- return (JSON.stringify({
1134
- type: 'module',
1135
- dependencies: {
1136
- screenci: screenciDependency,
1137
- '@playwright/test': '^1.59.0',
1138
- },
1139
- devDependencies,
1140
- }, null, 2) + '\n');
1141
- }
1142
- async function readCurrentScreenciVersion() {
1143
- const currentFileDir = dirname(fileURLToPath(import.meta.url));
1144
- const packageJsonPaths = [
1145
- resolve(currentFileDir, 'package.json'),
1146
- resolve(currentFileDir, '../package.json'),
1147
- ];
1148
- for (const packageJsonPath of packageJsonPaths) {
1149
- try {
1150
- const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf-8'));
1151
- if (typeof packageJson.version === 'string') {
1152
- return packageJson.version;
1153
- }
1154
- }
1155
- catch {
1156
- // Try the next candidate path.
1157
- }
1158
- }
1159
- return 'latest';
1160
- }
1161
- function generateReadme(projectName) {
1162
- return `# ${projectName}
1163
-
1164
- This project uses ScreenCI + Playwright to create and upload polished product videos.
1165
-
1166
- ## How video recording works
1167
-
1168
- Write video scripts in \`videos/*.video.ts\` and use \`video(...)\` calls to create product videos. These are very similar to Playwright \`.test.ts\` and \`test(...)\` calls.
1169
-
1170
- Learn more: https://screenci.com/docs
1171
-
1172
- ## Quick start
1173
-
1174
- 1. Create your own videos in \`videos/*.video.ts\`, either manually or with an AI agent using your source code or a URL.
1175
-
1176
- 2. Run videos locally to test the script working:
1177
-
1178
- \`npx screenci test\` or with UI mode: \`npx screenci test --ui\`
1179
-
1180
- 3. Record videos:
1181
-
1182
- \`npx screenci record\`
1183
-
1184
- 4. View results on screenci.com and optionally enable a public URL to embed the video on your site.
1185
- `;
1186
- }
1187
- function generateGitignore() {
1188
- return `/playwright-report/
1189
- .screenci
1190
- .playwright-cli/
1191
- node_modules/
1192
- .env
1193
- `;
1194
- }
1195
- function generateGithubAction() {
1196
- return `name: ScreenCI
1197
-
1198
- on:
1199
- push:
1200
- branches: [main]
1201
- workflow_dispatch:
1202
-
1203
- jobs:
1204
- record:
1205
- runs-on: ubuntu-latest
1206
- environment:
1207
- name: screenci
1208
- url: \${{ steps.record.outputs.screenci_project_url }}
1209
- steps:
1210
- - name: Check SCREENCI_SECRET
1211
- env:
1212
- SCREENCI_SECRET: \${{ secrets.SCREENCI_SECRET }}
1213
- run: |
1214
- if [ -z "$SCREENCI_SECRET" ]; then
1215
- echo "::error::SCREENCI_SECRET is not set. Copy it from https://app.screenci.com/secrets or ./.env, add it under Settings → Secrets and variables → Actions → Repository secrets, and then rerun this action."
1216
- exit 1
1217
- fi
1218
-
1219
- - uses: actions/checkout@v4
1220
-
1221
- - uses: actions/setup-node@v4
1222
- with:
1223
- node-version: 24
1224
- cache: npm
1225
- cache-dependency-path: package-lock.json
1226
-
1227
- - name: Install dependencies
1228
- working-directory: .
1229
- run: npm ci
1230
-
1231
- - name: Cache Playwright Chromium
1232
- uses: actions/cache@v5
1233
- id: pw-cache
1234
- with:
1235
- path: ~/.cache/ms-playwright
1236
- key: playwright-\${{ runner.os }}-\${{ hashFiles('package-lock.json') }}
1237
-
1238
- - name: Install Chromium
1239
- if: steps.pw-cache.outputs.cache-hit != 'true'
1240
- working-directory: .
1241
- run: npx playwright install chromium
1242
-
1243
- - id: record
1244
- name: Record
1245
- working-directory: .
1246
- env:
1247
- SCREENCI_SECRET: \${{ secrets.SCREENCI_SECRET }}
1248
- run: npx screenci record
1249
- `;
1250
- }
1251
1144
  function openBrowser(url) {
1252
1145
  try {
1253
1146
  if (process.platform === 'win32') {
@@ -1307,91 +1200,6 @@ async function performBrowserLogin(appUrl) {
1307
1200
  server.on('close', () => clearTimeout(timeout));
1308
1201
  });
1309
1202
  }
1310
- function generateExampleVideo() {
1311
- return `import { autoZoom, createNarration, hide, video, voices } from 'screenci'
1312
-
1313
- const narration = createNarration({
1314
- voice: { name: voices.Sophie },
1315
- languages: {
1316
- en: {
1317
- cues: {
1318
- intro:
1319
- 'This video shows how to get started with ScreenCI [pronounce: screen see eye].',
1320
- docs: 'You can find the documentation linked right on the front page.',
1321
- },
1322
- },
1323
- },
1324
- })
1325
-
1326
- video('How to get started', async ({ page }) => {
1327
- await hide(async () => {
1328
- await page.goto('https://screenci.com')
1329
- await page.getByText('ScreenCI').first().waitFor()
1330
- })
1331
-
1332
- await narration.intro()
1333
- await narration.docs()
1334
-
1335
- await autoZoom(async () => {
1336
- await page.getByRole('link', { name: 'View Documentation' }).click()
1337
- })
1338
-
1339
- await page.getByRole('heading', { level: 1, name: 'Installation' }).first().waitFor()
1340
- })
1341
- `;
1342
- }
1343
- function getDefaultInitProjectName() {
1344
- const directoryName = basename(getInitProjectRoot());
1345
- return directoryName.length > 0 ? directoryName : 'screenci-project';
1346
- }
1347
- async function promptProjectName() {
1348
- return input({
1349
- message: 'Project name:',
1350
- default: getDefaultInitProjectName(),
1351
- });
1352
- }
1353
- async function promptYesNo(message, defaultValue) {
1354
- const answer = await input({
1355
- message,
1356
- default: defaultValue ? 'y' : 'n',
1357
- validate: (value) => {
1358
- const normalized = value.trim().toLowerCase();
1359
- if (normalized === '' ||
1360
- normalized === 'y' ||
1361
- normalized === 'yes' ||
1362
- normalized === 'n' ||
1363
- normalized === 'no') {
1364
- return true;
1365
- }
1366
- return 'Enter y or n';
1367
- },
1368
- });
1369
- const normalized = answer.trim().toLowerCase();
1370
- if (normalized === '')
1371
- return defaultValue;
1372
- return normalized === 'y' || normalized === 'yes';
1373
- }
1374
- async function promptInitGithubActionWorkflow() {
1375
- return promptYesNo('Add a GitHub Actions workflow? (Y/n)', true);
1376
- }
1377
- async function promptInitPlaywrightBrowsers() {
1378
- return promptYesNo("Install Playwright browsers (can be done manually via 'npx playwright install chromium')? (Y/n)", true);
1379
- }
1380
- async function promptInitPlaywrightOsDependencies() {
1381
- return promptYesNo("Install Playwright operating system dependencies (might require sudo / root and can be done manually via 'npx playwright install-deps chromium')? (y/N)", false);
1382
- }
1383
- async function promptInitScreenCISkill() {
1384
- return promptYesNo("Install the ScreenCI skill for AI agents (can be done manually via 'npx -y skills add screenci/screenci --skill screenci -y')? (Y/n)", true);
1385
- }
1386
- async function promptInitPlaywrightCliSkill() {
1387
- return promptYesNo("Install playwright-cli for URL-based browser inspection (can be done manually via 'npx -y skills add screenci/screenci --skill playwright-cli -y && npm install @playwright/cli')? (Y/n)", true);
1388
- }
1389
- function getInitProjectRoot() {
1390
- return process.env['SCREENCI_INIT_CWD'] ?? process.cwd();
1391
- }
1392
- function getInitScreenciDependencyOverride() {
1393
- return process.env['SCREENCI_INIT_SCREENCI_DEPENDENCY'];
1394
- }
1395
1203
  export async function ensureScreenciSecret(resolvedConfigPath) {
1396
1204
  const existingSecret = process.env.SCREENCI_SECRET;
1397
1205
  if (existingSecret)
@@ -1416,133 +1224,6 @@ export async function ensureScreenciSecret(resolvedConfigPath) {
1416
1224
  return undefined;
1417
1225
  }
1418
1226
  }
1419
- async function runInit(projectNameArg, options) {
1420
- const { verbose, yes, agent } = options;
1421
- const initCwd = getInitProjectRoot();
1422
- let projectName = projectNameArg?.trim();
1423
- if (!projectName) {
1424
- projectName = yes ? getDefaultInitProjectName() : await promptProjectName();
1425
- }
1426
- if (!projectName) {
1427
- logger.error('Error: Project name is required');
1428
- process.exit(1);
1429
- }
1430
- const projectDir = initCwd;
1431
- const githubWorkflowsDir = resolve(projectDir, '.github', 'workflows');
1432
- const githubActionPath = resolve(githubWorkflowsDir, 'screenci.yaml');
1433
- const shouldAddGithubActionWorkflow = yes
1434
- ? true
1435
- : await promptInitGithubActionWorkflow();
1436
- const shouldInstallPlaywrightBrowsers = yes
1437
- ? true
1438
- : await promptInitPlaywrightBrowsers();
1439
- const shouldInstallPlaywrightOsDependencies = yes
1440
- ? false
1441
- : await promptInitPlaywrightOsDependencies();
1442
- const shouldInstallScreenCISkill = yes
1443
- ? true
1444
- : await promptInitScreenCISkill();
1445
- const shouldInstallPlaywrightCli = yes
1446
- ? true
1447
- : await promptInitPlaywrightCliSkill();
1448
- if (shouldAddGithubActionWorkflow && existsSync(githubActionPath)) {
1449
- logger.error('Error: GitHub Actions workflow ".github/workflows/screenci.yaml" already exists');
1450
- process.exit(1);
1451
- }
1452
- const skills = [];
1453
- if (shouldInstallScreenCISkill) {
1454
- skills.push('screenci');
1455
- }
1456
- if (shouldInstallPlaywrightCli) {
1457
- skills.push('playwright-cli');
1458
- }
1459
- const skillsArgs = skills.length === 0
1460
- ? null
1461
- : [
1462
- '-y',
1463
- 'skills',
1464
- 'add',
1465
- 'screenci/screenci',
1466
- ...(agent ? ['--agent', agent] : []),
1467
- ...skills.flatMap((skillName) => ['--skill', skillName]),
1468
- '-y',
1469
- ];
1470
- const skillsCommand = skillsArgs === null ? null : `npx ${skillsArgs.join(' ')}`;
1471
- const screenciDependency = getInitScreenciDependencyOverride() ?? (await readCurrentScreenciVersion());
1472
- logger.info("Initializing project in '.'");
1473
- await mkdir(resolve(projectDir, 'videos'), { recursive: true });
1474
- if (shouldAddGithubActionWorkflow) {
1475
- await mkdir(githubWorkflowsDir, { recursive: true });
1476
- }
1477
- await writeFile(resolve(projectDir, 'screenci.config.ts'), generateConfig(projectName));
1478
- await writeFile(resolve(projectDir, 'package.json'), generatePackageJson(shouldInstallPlaywrightCli, screenciDependency));
1479
- await writeFile(resolve(projectDir, 'README.md'), generateReadme(projectName));
1480
- await writeFile(resolve(projectDir, '.gitignore'), generateGitignore());
1481
- await writeFile(resolve(projectDir, 'videos', 'example.video.ts'), generateExampleVideo());
1482
- if (shouldAddGithubActionWorkflow) {
1483
- await writeFile(githubActionPath, generateGithubAction());
1484
- }
1485
- logger.info(`Initialized screenci project "${projectName}" in .`);
1486
- logger.info('Files created:');
1487
- logger.info(' screenci.config.ts');
1488
- logger.info(' package.json');
1489
- logger.info(' README.md');
1490
- logger.info(' .gitignore');
1491
- logger.info(' videos/example.video.ts');
1492
- if (shouldAddGithubActionWorkflow) {
1493
- logger.info(' .github/workflows/screenci.yaml');
1494
- }
1495
- logger.info('');
1496
- if (skillsArgs !== null) {
1497
- if (verbose) {
1498
- logger.info(`Running '${skillsCommand}'...`);
1499
- await spawnInherited('npx', skillsArgs, projectDir, 'screenci init');
1500
- }
1501
- else {
1502
- const spinner = ora('Adding selected AI skills...').start();
1503
- try {
1504
- await spawnSilent('npx', skillsArgs, projectDir);
1505
- spinner.succeed('Selected AI skills added');
1506
- }
1507
- catch (err) {
1508
- spinner.fail('AI skills install failed');
1509
- throw err;
1510
- }
1511
- }
1512
- }
1513
- const installArgs = ['install', '--include=dev'];
1514
- if (verbose) {
1515
- logger.info(`Running 'npm ${installArgs.join(' ')}'...`);
1516
- await spawnInherited('npm', installArgs, projectDir, 'screenci init');
1517
- }
1518
- else {
1519
- const spinner = ora('Running npm install...').start();
1520
- try {
1521
- await spawnSilent('npm', installArgs, projectDir);
1522
- spinner.succeed('npm install complete');
1523
- }
1524
- catch (err) {
1525
- spinner.fail('npm install failed');
1526
- throw err;
1527
- }
1528
- }
1529
- if (shouldInstallPlaywrightBrowsers) {
1530
- logger.info("Installing Playwright Chromium with 'npx playwright install chromium'...");
1531
- await spawnInherited('npx', ['playwright', 'install', 'chromium'], projectDir, 'screenci init');
1532
- logger.info(`${pc.green('✔')} Playwright Chromium installed successfully`);
1533
- }
1534
- if (shouldInstallPlaywrightOsDependencies) {
1535
- logger.info("Installing Playwright operating system dependencies with 'npx playwright install-deps chromium'...");
1536
- await spawnInherited('npx', ['playwright', 'install-deps', 'chromium'], projectDir, 'screenci init');
1537
- logger.info(`${pc.green('✔')} Playwright operating system dependencies installed successfully`);
1538
- }
1539
- logger.info('');
1540
- logger.info('Next steps:');
1541
- logger.info(' Read README.md for setup and recording flow');
1542
- logger.info(' Docs: https://screenci.com/docs');
1543
- logger.info(' npx screenci test');
1544
- logger.info(' npx screenci record');
1545
- }
1546
1227
  export async function main() {
1547
1228
  if (process.argv.length <= 2) {
1548
1229
  logger.error('Error: No command provided');
@@ -1550,6 +1231,7 @@ export async function main() {
1550
1231
  process.exit(1);
1551
1232
  }
1552
1233
  const program = new Command();
1234
+ const defaultPackageManager = determinePackageManager();
1553
1235
  program.name('screenci');
1554
1236
  program.exitOverride();
1555
1237
  // record command — playwright args pass through as-is
@@ -1634,7 +1316,7 @@ export async function main() {
1634
1316
  }
1635
1317
  if (hadFailures) {
1636
1318
  for (const failedVideo of failedVideoMessages) {
1637
- logger.warn(`${failedVideo.videoName}: ${failedVideo.message}`);
1319
+ logger.warn(formatFailedVideoMessage(failedVideo.videoName, failedVideo.message));
1638
1320
  }
1639
1321
  logger.warn(`Not all recordings succeeded to upload. Failed videos: ${failedVideoNames.join(', ') || 'unknown'}. Some videos may be missing from the project.`);
1640
1322
  if (playwrightFailure === null) {
@@ -1662,10 +1344,12 @@ export async function main() {
1662
1344
  .allowUnknownOption(true)
1663
1345
  .action(async () => {
1664
1346
  const parsed = parseConfigCliArgs(getSubcommandArgv('test'));
1347
+ let configMockRecord = false;
1665
1348
  const resolvedConfigPath = findScreenCIConfig(parsed.configPath);
1666
1349
  if (resolvedConfigPath) {
1667
1350
  try {
1668
1351
  const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
1352
+ configMockRecord = screenciConfig.test?.mockRecord ?? false;
1669
1353
  if (screenciConfig.envFile) {
1670
1354
  const envFilePath = resolve(dirname(resolvedConfigPath), screenciConfig.envFile);
1671
1355
  loadEnvFile(envFilePath, true);
@@ -1675,7 +1359,7 @@ export async function main() {
1675
1359
  logger.warn('Failed to load config for test env:', err);
1676
1360
  }
1677
1361
  }
1678
- await run('test', parsed.otherArgs, parsed.configPath, parsed.verbose, parsed.mockRecord);
1362
+ await run('test', parsed.otherArgs, parsed.configPath, parsed.verbose, parsed.mockRecord || configMockRecord);
1679
1363
  if (process.env.SCREENCI_RECORDING === 'true')
1680
1364
  return;
1681
1365
  logger.info('');
@@ -1707,6 +1391,7 @@ export async function main() {
1707
1391
  .command('init [name]')
1708
1392
  .description('Initialize a new screenci project')
1709
1393
  .option('--agent <name>', 'target agent for skills install, e.g. opencode. Supported agents: https://github.com/vercel-labs/skills#supported-agents')
1394
+ .option('--package-manager <manager>', `package manager to use: npm or pnpm (default: ${defaultPackageManager})`)
1710
1395
  .option('-y, --yes', 'accept init defaults')
1711
1396
  .option('-v, --verbose', 'verbose output')
1712
1397
  .action(async (name, options) => {
@@ -1714,6 +1399,7 @@ export async function main() {
1714
1399
  await runInit(name, {
1715
1400
  verbose: options['verbose'] ?? false,
1716
1401
  yes: options['yes'] ?? false,
1402
+ packageManager: parsePackageManager(options['packageManager']),
1717
1403
  ...(agent !== undefined ? { agent } : {}),
1718
1404
  });
1719
1405
  });
@@ -1830,39 +1516,6 @@ function validateArgs(args) {
1830
1516
  }
1831
1517
  }
1832
1518
  }
1833
- function spawnInherited(cmd, args, cwd, activityLabel = cmd) {
1834
- const spawnSpec = resolveSpawnSpec(cmd, args);
1835
- const child = spawn(spawnSpec.command, spawnSpec.args, {
1836
- stdio: 'inherit',
1837
- ...(spawnSpec.shell !== undefined ? { shell: spawnSpec.shell } : {}),
1838
- ...(cwd ? { cwd } : {}),
1839
- });
1840
- const childSignals = forwardChildSignals(child, activityLabel);
1841
- return new Promise((resolve, reject) => {
1842
- child.on('close', (code, signal) => {
1843
- const forwardedSignal = childSignals.getForwardedSignal();
1844
- childSignals.cleanup();
1845
- if (forwardedSignal) {
1846
- process.kill(process.pid, forwardedSignal);
1847
- return;
1848
- }
1849
- if (signal) {
1850
- process.kill(process.pid, signal);
1851
- return;
1852
- }
1853
- else if (code === 0) {
1854
- resolve();
1855
- }
1856
- else {
1857
- reject(new Error(`${cmd} exited with code ${code}`));
1858
- }
1859
- });
1860
- child.on('error', (err) => {
1861
- childSignals.cleanup();
1862
- reject(err);
1863
- });
1864
- });
1865
- }
1866
1519
  async function run(command, additionalArgs, customConfigPath, verbose = false, mockRecord = false) {
1867
1520
  const configPath = findScreenCIConfig(customConfigPath);
1868
1521
  if (!configPath) {
@@ -1902,6 +1555,11 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
1902
1555
  stdio: 'inherit',
1903
1556
  ...(process.platform !== 'win32' ? { detached: true } : {}),
1904
1557
  ...(spawnSpec.shell !== undefined ? { shell: spawnSpec.shell } : {}),
1558
+ ...(spawnSpec.windowsVerbatimArguments !== undefined
1559
+ ? {
1560
+ windowsVerbatimArguments: spawnSpec.windowsVerbatimArguments,
1561
+ }
1562
+ : {}),
1905
1563
  env: {
1906
1564
  ...envForChild,
1907
1565
  // Enable recording only for record command