screenci 0.0.9 → 0.0.10

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.
@@ -0,0 +1,34 @@
1
+ # Recording runtime ───────────────────────────────────────────────────────────
2
+ FROM docker.io/library/node:25.2.1-slim
3
+
4
+ WORKDIR /app
5
+
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ xvfb \
8
+ ffmpeg \
9
+ x11-utils \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # ── Dependency layer (cached until package.json changes) ──────────────────────
13
+ # Install screenci as a workspace package so npm creates the bin link.
14
+ COPY package.json ./screenci/
15
+ RUN printf '{"private":true,"workspaces":["screenci"]}' > package.json && npm install
16
+
17
+ # Playwright browser download: only re-runs when the playwright version changes.
18
+ RUN npx playwright install chromium --with-deps
19
+
20
+ # ── screenci build output ─────────────────────────────────────────────────────
21
+ # Copy pre-built dist directly from the screenci package directory.
22
+ COPY dist ./screenci/dist/
23
+
24
+ # Explicit bin wrapper — no npm bin-linking magic needed.
25
+ RUN printf '#!/bin/sh\nexec node /app/screenci/dist/cli.js "$@"\n' > /app/node_modules/.bin/screenci && \
26
+ chmod +x /app/node_modules/.bin/screenci
27
+
28
+ # Create .screenci directory for recordings
29
+ RUN mkdir -p .screenci
30
+
31
+ # Add node_modules/.bin to PATH
32
+ ENV PATH="/app/node_modules/.bin:${PATH}"
33
+
34
+ CMD ["echo", "Container ready"]
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../cli.ts"],"names":[],"mappings":";AAurBA,wBAAsB,IAAI,kBAoJzB;AAqED,wBAAgB,sBAAsB,IAAI,MAAM,CAc/C"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../cli.ts"],"names":[],"mappings":";AAgzBA,wBAAsB,IAAI,kBA2JzB;AAqED,wBAAgB,sBAAsB,IAAI,MAAM,CAc/C"}
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env -S npx tsx
2
2
  import { spawn, spawnSync } from 'child_process';
3
3
  import { createReadStream } from 'fs';
4
- import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync } from 'fs';
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, } from 'fs';
5
5
  import { createHash } from 'crypto';
6
6
  import { createServer } from 'http';
7
7
  import { mkdir, readdir, readFile, stat, writeFile } from 'fs/promises';
@@ -9,6 +9,80 @@ import { dirname, relative as pathRelative, resolve } from 'path';
9
9
  import { createInterface } from 'readline/promises';
10
10
  import { fileURLToPath } from 'url';
11
11
  import { logger } from './src/logger.js';
12
+ function writeInline(msg) {
13
+ process.stdout.write(msg);
14
+ }
15
+ function completeInline(msg) {
16
+ process.stdout.write(`\r\x1b[K${msg}\n`);
17
+ }
18
+ function parseDockerfileVersion(dockerfilePath) {
19
+ let content;
20
+ try {
21
+ content = readFileSync(dockerfilePath, 'utf-8');
22
+ }
23
+ catch {
24
+ return 'unknown';
25
+ }
26
+ const fromLine = content
27
+ .split('\n')
28
+ .find((line) => line.trim().toUpperCase().startsWith('FROM'));
29
+ if (!fromLine)
30
+ return 'unknown';
31
+ const match = fromLine.match(/:([^\s@]+)/);
32
+ return match?.[1] ?? 'unknown';
33
+ }
34
+ function spawnSilent(cmd, args) {
35
+ return new Promise((resolve, reject) => {
36
+ const child = spawn(cmd, args, { stdio: 'pipe' });
37
+ child.on('close', (code) => {
38
+ if (code === 0) {
39
+ resolve();
40
+ }
41
+ else {
42
+ reject(new Error(`${cmd} exited with code ${code}`));
43
+ }
44
+ });
45
+ child.on('error', reject);
46
+ });
47
+ }
48
+ const CONTAINER_LOG_FILTER = [
49
+ /^Running ScreenCI /,
50
+ /^Using config:/,
51
+ /^Starting Xvfb /,
52
+ /^Xvfb started /,
53
+ /^Recording video to:/,
54
+ /^Recording with /,
55
+ /^Stopping recording\.\.\./,
56
+ /^FFmpeg exited /,
57
+ /^Video saved to:/,
58
+ /^Events saved to:/,
59
+ ];
60
+ function spawnContainerRecording(cmd, args) {
61
+ return new Promise((resolve, reject) => {
62
+ const child = spawn(cmd, args, { stdio: ['inherit', 'pipe', 'pipe'] });
63
+ function forwardFiltered(chunk, out) {
64
+ const lines = chunk.toString().split('\n');
65
+ for (const line of lines) {
66
+ if (line === '')
67
+ continue;
68
+ if (!CONTAINER_LOG_FILTER.some((re) => re.test(line.trimStart()))) {
69
+ out.write(line + '\n');
70
+ }
71
+ }
72
+ }
73
+ child.stdout?.on('data', (chunk) => forwardFiltered(chunk, process.stdout));
74
+ child.stderr?.on('data', (chunk) => forwardFiltered(chunk, process.stderr));
75
+ child.on('close', (code) => {
76
+ if (code === 0) {
77
+ resolve();
78
+ }
79
+ else {
80
+ reject(new Error(`${cmd} exited with code ${code}`));
81
+ }
82
+ });
83
+ child.on('error', reject);
84
+ });
85
+ }
12
86
  function clearDirectory(dir) {
13
87
  mkdirSync(dir, { recursive: true });
14
88
  for (const entry of readdirSync(dir)) {
@@ -54,6 +128,8 @@ function parseArgs(args) {
54
128
  }
55
129
  let configPath;
56
130
  let noContainer = false;
131
+ let imageTag;
132
+ let verbose = false;
57
133
  const otherArgs = [];
58
134
  for (let i = 1; i < args.length; i++) {
59
135
  const arg = args[i];
@@ -71,11 +147,25 @@ function parseArgs(args) {
71
147
  else if (arg === '--no-container') {
72
148
  noContainer = true;
73
149
  }
150
+ else if (arg === '--verbose' || arg === '-v') {
151
+ verbose = true;
152
+ }
153
+ else if (arg === '--tag') {
154
+ const nextArg = args[i + 1];
155
+ if (nextArg !== undefined) {
156
+ imageTag = nextArg;
157
+ i++; // skip next arg
158
+ }
159
+ else {
160
+ logger.error('Error: --tag requires a tag argument');
161
+ process.exit(1);
162
+ }
163
+ }
74
164
  else if (arg !== undefined) {
75
165
  otherArgs.push(arg);
76
166
  }
77
167
  }
78
- return { command, configPath, noContainer, otherArgs };
168
+ return { command, configPath, noContainer, imageTag, verbose, otherArgs };
79
169
  }
80
170
  async function findLatestEntry(screenciDir) {
81
171
  let entries;
@@ -187,11 +277,12 @@ async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specif
187
277
  }
188
278
  catch {
189
279
  logger.warn('No .screenci directory found, skipping upload');
190
- return;
280
+ return null;
191
281
  }
192
282
  if (specificEntry !== undefined) {
193
283
  entries = entries.filter((e) => e === specificEntry);
194
284
  }
285
+ let firstProjectId = null;
195
286
  for (const entry of entries) {
196
287
  const dataJsonPath = resolve(screenciDir, entry, 'data.json');
197
288
  if (!existsSync(dataJsonPath))
@@ -206,7 +297,7 @@ async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specif
206
297
  continue;
207
298
  }
208
299
  const videoName = data.metadata?.videoName ?? entry;
209
- logger.info(`Uploading "${videoName}"...`);
300
+ writeInline(`Uploading "${videoName}"...`);
210
301
  try {
211
302
  // Step 1: register upload and get recordingId
212
303
  const startResponse = await fetch(`${apiUrl}/cli/upload/start`, {
@@ -219,10 +310,14 @@ async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specif
219
310
  });
220
311
  if (!startResponse.ok) {
221
312
  const text = await startResponse.text();
313
+ process.stdout.write('\n');
222
314
  logger.warn(`Failed to start upload for "${videoName}": ${startResponse.status} ${text}`);
223
315
  continue;
224
316
  }
225
- const { recordingId } = (await startResponse.json());
317
+ const { recordingId, projectId } = (await startResponse.json());
318
+ if (firstProjectId === null) {
319
+ firstProjectId = projectId;
320
+ }
226
321
  // Step 1b: upload asset files referenced in data.json
227
322
  await uploadAssets(data, apiUrl, secret, recordingId, resolve(screenciDir, '..'));
228
323
  // Step 2: stream the recording video file (if it exists)
@@ -243,16 +338,19 @@ async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specif
243
338
  });
244
339
  if (!recordingResponse.ok) {
245
340
  const text = await recordingResponse.text();
341
+ process.stdout.write('\n');
246
342
  logger.warn(`Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}`);
247
343
  continue;
248
344
  }
249
345
  }
250
- logger.info(`Uploaded "${videoName}" successfully`);
346
+ completeInline(`Uploading "${videoName}" ✓`);
251
347
  }
252
348
  catch (err) {
349
+ process.stdout.write('\n');
253
350
  logger.warn(`Network error uploading "${videoName}":`, err);
254
351
  }
255
352
  }
353
+ return firstProjectId;
256
354
  }
257
355
  async function uploadLatest(configPath) {
258
356
  const resolvedConfigPath = findScreenCIConfig(configPath);
@@ -281,11 +379,9 @@ async function uploadLatest(configPath) {
281
379
  logger.warn(`Failed to load env file ${envFilePath}:`, err);
282
380
  }
283
381
  }
284
- const convexUrl = screenciConfig.apiUrl ?? process.env.SCREENCI_URL;
285
- if (!convexUrl) {
286
- logger.error('No API URL configured. Set apiUrl in screenci.config.ts or SCREENCI_URL env var.');
287
- process.exit(1);
288
- }
382
+ const apiUrl = process.env.DEV_PORT
383
+ ? `http://localhost:${process.env.DEV_PORT}`
384
+ : 'https://api.screenci.com';
289
385
  const secret = process.env.SCREENCI_SECRET;
290
386
  if (!secret) {
291
387
  logger.error('No secret configured. Set SCREENCI_SECRET in your .env file (get it from the API Key page in the dashboard).');
@@ -298,15 +394,24 @@ async function uploadLatest(configPath) {
298
394
  logger.warn('No recordings found in .screenci directory');
299
395
  return;
300
396
  }
397
+ const appUrl = process.env.SCREENCI_APP_URL
398
+ ? process.env.SCREENCI_APP_URL
399
+ : process.env.DEV_PORT
400
+ ? `http://localhost:${process.env.DEV_PORT}`
401
+ : 'https://app.screenci.com';
301
402
  logger.info(`Uploading latest recording: "${latestEntry}"`);
302
- await uploadRecordings(screenciDir, screenciConfig.projectName, convexUrl, secret, latestEntry);
403
+ const projectId = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret, latestEntry);
404
+ if (projectId !== null) {
405
+ logger.info('');
406
+ logger.info('Recording finished, results available at:');
407
+ logger.info(`${appUrl}/project/${projectId}`);
408
+ }
303
409
  }
304
410
  function generateConfig(projectName) {
305
411
  return `import { defineConfig } from 'screenci'
306
412
 
307
413
  export default defineConfig({
308
414
  projectName: ${JSON.stringify(projectName)},
309
- apiUrl: process.env.SCREENCI_URL ?? 'http://localhost:8787',
310
415
  envFile: '.env',
311
416
  videoDir: './videos',
312
417
  forbidOnly: !!process.env.CI,
@@ -443,9 +548,9 @@ async function performBrowserLogin(appUrl) {
443
548
  const port = server.address().port;
444
549
  const callbackUrl = `http://localhost:${port}/callback`;
445
550
  const loginUrl = `${appUrl}/cli-auth?callback=${encodeURIComponent(callbackUrl)}`;
446
- logger.info('Opening browser for authentication...');
447
551
  logger.info(`If the browser does not open automatically, visit:`);
448
- logger.info(` ${loginUrl}`);
552
+ logger.info(loginUrl);
553
+ logger.info('');
449
554
  openBrowser(loginUrl);
450
555
  });
451
556
  const timeout = setTimeout(() => {
@@ -494,8 +599,9 @@ async function runInitAuth() {
494
599
  (devPort ? `http://localhost:${devPort}` : 'https://app.screenci.com');
495
600
  try {
496
601
  const secret = await performBrowserLogin(appUrl);
497
- await writeFile(resolve(process.cwd(), '.env'), `SCREENCI_SECRET=${secret}\n`);
498
- logger.info('API key saved to .env');
602
+ const savePath = resolve(process.cwd(), '.env');
603
+ await writeFile(savePath, `SCREENCI_SECRET=${secret}\n`);
604
+ logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
499
605
  }
500
606
  catch (err) {
501
607
  const msg = err instanceof Error ? err.message : String(err);
@@ -552,7 +658,7 @@ async function runInit(projectNameArg, localPackagePath) {
552
658
  }
553
659
  export async function main() {
554
660
  const args = process.argv.slice(2);
555
- const { command, configPath, noContainer, otherArgs } = parseArgs(args);
661
+ const { command, configPath, noContainer, imageTag, verbose, otherArgs } = parseArgs(args);
556
662
  switch (command) {
557
663
  case 'record': {
558
664
  const useContainer = !noContainer && process.env.SCREENCI_IN_CONTAINER !== 'true';
@@ -589,7 +695,7 @@ export async function main() {
589
695
  // Config import failed but SCREENCI_SECRET is already in env — continue
590
696
  }
591
697
  if (!process.env.SCREENCI_SECRET) {
592
- logger.info('SCREENCI_SECRET not found. Opening browser to sign in and select a plan...');
698
+ logger.info('No SCREENCI_SECRET in .env file, opening browser for authentication...');
593
699
  const devPort = process.env.DEV_PORT;
594
700
  const appUrl = process.env.SCREENCI_APP_URL ??
595
701
  (devPort
@@ -599,12 +705,12 @@ export async function main() {
599
705
  const savePath = envFilePath ?? resolve(dirname(resolvedConfigForSecret), '.env');
600
706
  await writeFile(savePath, `SCREENCI_SECRET=${secret}\n`);
601
707
  process.env.SCREENCI_SECRET = secret;
602
- logger.info('API key saved.');
708
+ logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
603
709
  }
604
710
  }
605
711
  }
606
712
  if (useContainer) {
607
- await runWithContainer(otherArgs, configPath);
713
+ await runWithContainer(otherArgs, configPath, imageTag, verbose);
608
714
  }
609
715
  else {
610
716
  await run(command, otherArgs, configPath);
@@ -612,7 +718,7 @@ export async function main() {
612
718
  // Upload only from the host, not from inside the container
613
719
  if (process.env.SCREENCI_IN_CONTAINER === 'true')
614
720
  break;
615
- // After recording, upload results to Convex if configured
721
+ // After recording, upload results to API if configured
616
722
  const resolvedConfigPath = findScreenCIConfig(configPath);
617
723
  if (resolvedConfigPath) {
618
724
  try {
@@ -627,11 +733,14 @@ export async function main() {
627
733
  logger.warn(`Failed to load env file ${envFilePath}:`, err);
628
734
  }
629
735
  }
630
- const convexUrl = screenciConfig.apiUrl ?? process.env.SCREENCI_URL;
631
- if (!convexUrl) {
632
- logger.info('No API URL configured, skipping upload. Set apiUrl in screenci.config.ts or SCREENCI_URL env var.');
633
- break;
634
- }
736
+ const apiUrl = process.env.DEV_PORT
737
+ ? `http://localhost:${process.env.DEV_PORT}`
738
+ : 'https://api.screenci.com';
739
+ const appUrl = process.env.SCREENCI_APP_URL
740
+ ? process.env.SCREENCI_APP_URL
741
+ : process.env.DEV_PORT
742
+ ? `http://localhost:${process.env.DEV_PORT}`
743
+ : 'https://app.screenci.com';
635
744
  const secret = process.env.SCREENCI_SECRET;
636
745
  if (!secret) {
637
746
  logger.info('No secret configured, skipping upload. Set SCREENCI_SECRET in your .env file.');
@@ -639,7 +748,12 @@ export async function main() {
639
748
  }
640
749
  const configDir = dirname(resolvedConfigPath);
641
750
  const screenciDir = resolve(configDir, '.screenci');
642
- await uploadRecordings(screenciDir, screenciConfig.projectName, convexUrl, secret);
751
+ const projectId = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret);
752
+ if (projectId !== null) {
753
+ logger.info('');
754
+ logger.info('Recording finished, results available at:');
755
+ logger.info(`${appUrl}/project/${projectId}`);
756
+ }
643
757
  }
644
758
  catch (err) {
645
759
  logger.warn('Failed to load config for upload:', err);
@@ -746,7 +860,25 @@ export function detectContainerRuntime() {
746
860
  logger.error(' docker: https://docs.docker.com/get-docker/');
747
861
  process.exit(1);
748
862
  }
749
- async function runWithContainer(additionalArgs, customConfigPath) {
863
+ async function buildImage(cmd, args, label, verbose) {
864
+ if (verbose) {
865
+ await spawnInherited(cmd, args);
866
+ return;
867
+ }
868
+ writeInline(`${label}...`);
869
+ try {
870
+ await spawnSilent(cmd, args);
871
+ completeInline(`${label} ✓`);
872
+ }
873
+ catch (err) {
874
+ process.stdout.write('\n');
875
+ const msg = err instanceof Error ? err.message : String(err);
876
+ logger.error(msg);
877
+ logger.error('Run again with --verbose to see the full build output');
878
+ process.exit(1);
879
+ }
880
+ }
881
+ async function runWithContainer(additionalArgs, customConfigPath, imageTag, verbose = false) {
750
882
  const configPath = findScreenCIConfig(customConfigPath);
751
883
  if (!configPath) {
752
884
  const errorMsg = customConfigPath
@@ -768,43 +900,92 @@ async function runWithContainer(additionalArgs, customConfigPath) {
768
900
  process.exit(1);
769
901
  }
770
902
  const containerRuntime = detectContainerRuntime();
903
+ const ghcrImage = 'ghcr.io/screenci/record:latest';
904
+ const dockerfileVersion = parseDockerfileVersion(dockerfilePath);
771
905
  if (process.env['SCREENCI_LOCAL_IMAGE']) {
772
906
  logger.info('SCREENCI_LOCAL_IMAGE set — skipping screenci image build');
773
907
  }
908
+ else if (imageTag !== undefined) {
909
+ const remoteImage = `ghcr.io/screenci/record:${imageTag}`;
910
+ const imageExists = spawnSync(containerRuntime, ['image', 'exists', remoteImage], {
911
+ stdio: 'ignore',
912
+ }).status === 0;
913
+ logger.info(`Using image tag ${imageTag} instead of the version ${dockerfileVersion} from Dockerfile`);
914
+ if (!imageExists) {
915
+ await buildImage(containerRuntime, ['pull', remoteImage], 'Pulling image', verbose);
916
+ }
917
+ await spawnSilent(containerRuntime, ['tag', remoteImage, ghcrImage]);
918
+ }
774
919
  else {
775
920
  const cliDir = dirname(fileURLToPath(import.meta.url));
921
+ const screenciPackageRoot = resolve(cliDir, '..');
776
922
  const screenciDockerfilePath = resolve(cliDir, 'Dockerfile');
777
- logger.info(`Building container image with ${containerRuntime}...`);
778
- logger.info(`Using Dockerfile: ${screenciDockerfilePath}`);
779
- logger.info(`Build context: ${repoRoot}`);
780
- await spawnInherited(containerRuntime, [
781
- 'build',
782
- '-f',
783
- screenciDockerfilePath,
784
- '-t',
785
- 'screenci',
786
- repoRoot,
787
- ]);
788
- }
789
- logger.info(`Using Dockerfile: ${dockerfilePath}`);
790
- logger.info(`Build context: ${configDir}`);
791
- await spawnInherited(containerRuntime, [
792
- 'build',
793
- '-f',
794
- dockerfilePath,
795
- '-t',
796
- 'screenci',
797
- configDir,
798
- ]);
923
+ if (verbose) {
924
+ await spawnInherited(containerRuntime, [
925
+ 'build',
926
+ '-f',
927
+ screenciDockerfilePath,
928
+ '-t',
929
+ ghcrImage,
930
+ screenciPackageRoot,
931
+ ]);
932
+ await spawnInherited(containerRuntime, [
933
+ 'build',
934
+ '-f',
935
+ dockerfilePath,
936
+ '-t',
937
+ 'screenci',
938
+ configDir,
939
+ ]);
940
+ }
941
+ else {
942
+ writeInline('Building image...');
943
+ try {
944
+ await spawnSilent(containerRuntime, [
945
+ 'build',
946
+ '-f',
947
+ screenciDockerfilePath,
948
+ '-t',
949
+ ghcrImage,
950
+ screenciPackageRoot,
951
+ ]);
952
+ await spawnSilent(containerRuntime, [
953
+ 'build',
954
+ '-f',
955
+ dockerfilePath,
956
+ '-t',
957
+ 'screenci',
958
+ configDir,
959
+ ]);
960
+ completeInline('Building image ✓');
961
+ }
962
+ catch (err) {
963
+ process.stdout.write('\n');
964
+ const msg = err instanceof Error ? err.message : String(err);
965
+ logger.error(msg);
966
+ logger.error('Run again with --verbose to see the full build output');
967
+ process.exit(1);
968
+ }
969
+ }
970
+ }
971
+ if (imageTag !== undefined || process.env['SCREENCI_LOCAL_IMAGE']) {
972
+ await buildImage(containerRuntime, ['build', '-f', dockerfilePath, '-t', 'screenci', configDir], 'Building image', verbose);
973
+ }
799
974
  clearDirectory(resolve(configDir, '.screenci'));
800
- logger.info('Running recording in container...');
801
- await spawnInherited(containerRuntime, [
975
+ const secret = process.env['SCREENCI_SECRET'];
976
+ if (secret === undefined) {
977
+ logger.error('Error: SCREENCI_SECRET is not set');
978
+ process.exit(1);
979
+ }
980
+ await spawnContainerRecording(containerRuntime, [
802
981
  'run',
803
982
  '--rm',
804
983
  '-e',
805
984
  'SCREENCI_IN_CONTAINER=true',
806
985
  '-e',
807
986
  'SCREENCI_RECORD=true',
987
+ '-e',
988
+ `SCREENCI_SECRET=${secret}`,
808
989
  '-v',
809
990
  `${configDir}/.screenci:/app/.screenci`,
810
991
  '-v',
@@ -836,8 +1017,10 @@ async function run(command, additionalArgs, customConfigPath) {
836
1017
  const isHeaded = additionalArgs.includes('--headed');
837
1018
  const shouldUseUI = command === 'dev' && !isHeaded;
838
1019
  const mode = command === 'dev' ? (isHeaded ? 'headed mode' : 'UI mode') : 'recorder';
839
- logger.info(`Running ScreenCI ${mode} with npx...`);
840
- logger.info(`Using config: ${configPath}`);
1020
+ if (process.env.SCREENCI_IN_CONTAINER !== 'true') {
1021
+ logger.info(`Running ScreenCI ${mode} with npx...`);
1022
+ logger.info(`Using config: ${configPath}`);
1023
+ }
841
1024
  const playwrightArgs = [
842
1025
  'playwright',
843
1026
  'test',