screenci 0.0.4 → 0.0.6

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.
Files changed (94) hide show
  1. package/README.md +227 -0
  2. package/cli.ts +1111 -0
  3. package/dist/cli.d.ts +4 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +896 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/e2e/instrument.e2e.d.ts +2 -0
  8. package/dist/e2e/instrument.e2e.d.ts.map +1 -0
  9. package/dist/e2e/instrument.e2e.js +661 -0
  10. package/dist/e2e/instrument.e2e.js.map +1 -0
  11. package/dist/index.d.ts +18 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +15 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/playwright.config.d.ts +3 -0
  16. package/dist/playwright.config.d.ts.map +1 -0
  17. package/dist/playwright.config.js +21 -0
  18. package/dist/playwright.config.js.map +1 -0
  19. package/dist/reporter.d.ts +9 -0
  20. package/dist/reporter.d.ts.map +1 -0
  21. package/dist/reporter.js +49 -0
  22. package/dist/reporter.js.map +1 -0
  23. package/dist/src/asset.d.ts +90 -0
  24. package/dist/src/asset.d.ts.map +1 -0
  25. package/dist/src/asset.js +74 -0
  26. package/dist/src/asset.js.map +1 -0
  27. package/dist/src/autoZoom.d.ts +40 -0
  28. package/dist/src/autoZoom.d.ts.map +1 -0
  29. package/dist/src/autoZoom.js +88 -0
  30. package/dist/src/autoZoom.js.map +1 -0
  31. package/dist/src/caption.d.ts +152 -0
  32. package/dist/src/caption.d.ts.map +1 -0
  33. package/dist/src/caption.js +240 -0
  34. package/dist/src/caption.js.map +1 -0
  35. package/dist/src/caption.test-d.d.ts +2 -0
  36. package/dist/src/caption.test-d.d.ts.map +1 -0
  37. package/dist/src/caption.test-d.js +50 -0
  38. package/dist/src/caption.test-d.js.map +1 -0
  39. package/dist/src/config.d.ts +42 -0
  40. package/dist/src/config.d.ts.map +1 -0
  41. package/dist/src/config.js +147 -0
  42. package/dist/src/config.js.map +1 -0
  43. package/dist/src/defaults.d.ts +63 -0
  44. package/dist/src/defaults.d.ts.map +1 -0
  45. package/dist/src/defaults.js +66 -0
  46. package/dist/src/defaults.js.map +1 -0
  47. package/dist/src/dimensions.d.ts +29 -0
  48. package/dist/src/dimensions.d.ts.map +1 -0
  49. package/dist/src/dimensions.js +47 -0
  50. package/dist/src/dimensions.js.map +1 -0
  51. package/dist/src/events.d.ts +203 -0
  52. package/dist/src/events.d.ts.map +1 -0
  53. package/dist/src/events.js +227 -0
  54. package/dist/src/events.js.map +1 -0
  55. package/dist/src/hide.d.ts +27 -0
  56. package/dist/src/hide.d.ts.map +1 -0
  57. package/dist/src/hide.js +49 -0
  58. package/dist/src/hide.js.map +1 -0
  59. package/dist/src/instrument.d.ts +15 -0
  60. package/dist/src/instrument.d.ts.map +1 -0
  61. package/dist/src/instrument.js +910 -0
  62. package/dist/src/instrument.js.map +1 -0
  63. package/dist/src/logger.d.ts +7 -0
  64. package/dist/src/logger.d.ts.map +1 -0
  65. package/dist/src/logger.js +13 -0
  66. package/dist/src/logger.js.map +1 -0
  67. package/dist/src/reporter.d.ts +9 -0
  68. package/dist/src/reporter.d.ts.map +1 -0
  69. package/dist/src/reporter.js +50 -0
  70. package/dist/src/reporter.js.map +1 -0
  71. package/dist/src/sanitize.d.ts +5 -0
  72. package/dist/src/sanitize.d.ts.map +1 -0
  73. package/dist/src/sanitize.js +11 -0
  74. package/dist/src/sanitize.js.map +1 -0
  75. package/dist/src/types.d.ts +544 -0
  76. package/dist/src/types.d.ts.map +1 -0
  77. package/dist/src/types.js +2 -0
  78. package/dist/src/types.js.map +1 -0
  79. package/dist/src/video.d.ts +138 -0
  80. package/dist/src/video.d.ts.map +1 -0
  81. package/dist/src/video.js +415 -0
  82. package/dist/src/video.js.map +1 -0
  83. package/dist/src/voices.d.ts +60 -0
  84. package/dist/src/voices.d.ts.map +1 -0
  85. package/dist/src/voices.js +42 -0
  86. package/dist/src/voices.js.map +1 -0
  87. package/dist/src/xvfb.d.ts +22 -0
  88. package/dist/src/xvfb.d.ts.map +1 -0
  89. package/dist/src/xvfb.js +87 -0
  90. package/dist/src/xvfb.js.map +1 -0
  91. package/dist/tsconfig.tsbuildinfo +1 -0
  92. package/package.json +45 -4
  93. package/bin/index.js +0 -3
  94. package/index.js +0 -1
package/cli.ts ADDED
@@ -0,0 +1,1111 @@
1
+ #!/usr/bin/env -S npx tsx
2
+
3
+ import { spawn, spawnSync } from 'child_process'
4
+ import { createReadStream } from 'fs'
5
+ import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync } from 'fs'
6
+ import { createHash } from 'crypto'
7
+ import { createServer } from 'http'
8
+ import type { AddressInfo } from 'net'
9
+ import { mkdir, readdir, readFile, stat, writeFile } from 'fs/promises'
10
+ import { dirname, relative as pathRelative, resolve } from 'path'
11
+ import { createInterface } from 'readline/promises'
12
+ import { fileURLToPath } from 'url'
13
+ import { logger } from './src/logger.js'
14
+ import type { RecordingData } from './src/events.js'
15
+ import type { ScreenCIConfig } from './src/types.js'
16
+
17
+ function clearDirectory(dir: string): void {
18
+ mkdirSync(dir, { recursive: true })
19
+ for (const entry of readdirSync(dir)) {
20
+ rmSync(resolve(dir, entry), { recursive: true, force: true })
21
+ }
22
+ }
23
+
24
+ function findScreenCIConfig(customPath?: string): string | null {
25
+ if (customPath) {
26
+ const resolvedPath = resolve(process.cwd(), customPath)
27
+ if (existsSync(resolvedPath)) {
28
+ return resolvedPath
29
+ }
30
+ return null
31
+ }
32
+
33
+ const cwd = process.cwd()
34
+ const configPath = resolve(cwd, 'screenci.config.ts')
35
+
36
+ if (existsSync(configPath)) {
37
+ return configPath
38
+ }
39
+
40
+ return null
41
+ }
42
+
43
+ function findRepoRoot(startDir: string): string | null {
44
+ let current = startDir
45
+ while (true) {
46
+ if (
47
+ existsSync(resolve(current, '.git')) ||
48
+ existsSync(resolve(current, 'pnpm-workspace.yaml'))
49
+ ) {
50
+ return current
51
+ }
52
+ const parent = resolve(current, '..')
53
+ if (parent === current) return null
54
+ current = parent
55
+ }
56
+ }
57
+
58
+ function parseArgs(args: string[]): {
59
+ command: string
60
+ configPath: string | undefined
61
+ noContainer: boolean
62
+ otherArgs: string[]
63
+ } {
64
+ const command = args[0]
65
+ if (command === undefined) {
66
+ logger.error('Error: No command provided')
67
+ logger.error('Available commands: record, dev, upload-latest, init')
68
+ process.exit(1)
69
+ }
70
+ let configPath: string | undefined
71
+ let noContainer = false
72
+ const otherArgs: string[] = []
73
+
74
+ for (let i = 1; i < args.length; i++) {
75
+ const arg = args[i]
76
+ if (arg === '--config' || arg === '-c') {
77
+ const nextArg = args[i + 1]
78
+ if (nextArg !== undefined) {
79
+ configPath = nextArg
80
+ i++ // skip next arg
81
+ } else {
82
+ logger.error('Error: --config requires a path argument')
83
+ process.exit(1)
84
+ }
85
+ } else if (arg === '--no-container') {
86
+ noContainer = true
87
+ } else if (arg !== undefined) {
88
+ otherArgs.push(arg)
89
+ }
90
+ }
91
+
92
+ return { command, configPath, noContainer, otherArgs }
93
+ }
94
+
95
+ async function findLatestEntry(screenciDir: string): Promise<string | null> {
96
+ let entries: string[]
97
+ try {
98
+ entries = await readdir(screenciDir)
99
+ } catch {
100
+ return null
101
+ }
102
+
103
+ let latestEntry: string | null = null
104
+ let latestMtime = 0
105
+
106
+ for (const entry of entries) {
107
+ try {
108
+ const entryPath = resolve(screenciDir, entry)
109
+ const s = await stat(entryPath)
110
+ if (s.mtimeMs > latestMtime) {
111
+ latestMtime = s.mtimeMs
112
+ latestEntry = entry
113
+ }
114
+ } catch {
115
+ // skip unreadable entries
116
+ }
117
+ }
118
+
119
+ return latestEntry
120
+ }
121
+
122
+ async function uploadAssets(
123
+ data: RecordingData,
124
+ apiUrl: string,
125
+ secret: string,
126
+ recordingId: string,
127
+ configDir: string
128
+ ): Promise<void> {
129
+ type AssetStartEvent = Extract<
130
+ RecordingData['events'][number],
131
+ { type: 'assetStart' }
132
+ >
133
+ const assetEvents = (data.events as RecordingData['events']).filter(
134
+ (e): e is AssetStartEvent => e.type === 'assetStart'
135
+ )
136
+ if (assetEvents.length === 0) return
137
+
138
+ // Deduplicate by name — each unique asset name is uploaded once
139
+ const seenNames = new Set<string>()
140
+ for (const event of assetEvents) {
141
+ const assetPath = event.path
142
+ if (seenNames.has(event.name)) continue
143
+ seenNames.add(event.name)
144
+
145
+ // Resolve the asset file. Recording runs in a Docker container where configDir → /app,
146
+ // so stored paths may be container-internal absolute or relative paths.
147
+ // Resolution order:
148
+ // 1. Path as-is (works for absolute host paths)
149
+ // 2. Relative path resolved from configDir/videos (the video scripts directory)
150
+ // 3. Container path translated: /some/path → configDir/../some/path
151
+ const candidates = [
152
+ assetPath,
153
+ resolve(configDir, 'videos', assetPath),
154
+ resolve(configDir, pathRelative('/app', assetPath)),
155
+ ]
156
+ let fileBuffer: Buffer | undefined
157
+ let resolvedPath = assetPath
158
+ for (const candidate of candidates) {
159
+ try {
160
+ fileBuffer = await readFile(candidate)
161
+ resolvedPath = candidate
162
+ break
163
+ } catch {
164
+ // try next
165
+ }
166
+ }
167
+ if (fileBuffer === undefined) {
168
+ logger.warn(`Asset file not found, skipping upload: ${assetPath}`)
169
+ continue
170
+ }
171
+
172
+ const sha256 = createHash('sha256').update(fileBuffer).digest('hex')
173
+ const ext = assetPath.split('.').pop()?.toLowerCase() ?? 'bin'
174
+ const contentTypeMap: Record<string, string> = {
175
+ png: 'image/png',
176
+ jpg: 'image/jpeg',
177
+ jpeg: 'image/jpeg',
178
+ gif: 'image/gif',
179
+ webp: 'image/webp',
180
+ mp4: 'video/mp4',
181
+ webm: 'video/webm',
182
+ svg: 'image/svg+xml',
183
+ }
184
+ const contentType = contentTypeMap[ext] ?? 'application/octet-stream'
185
+
186
+ try {
187
+ const res = await fetch(`${apiUrl}/cli/upload/${recordingId}/asset`, {
188
+ method: 'PUT',
189
+ headers: {
190
+ 'Content-Type': 'application/json',
191
+ 'X-ScreenCI-Secret': secret,
192
+ },
193
+ body: JSON.stringify({
194
+ sha256,
195
+ fileBase64: fileBuffer.toString('base64'),
196
+ contentType,
197
+ assetName: event.name,
198
+ }),
199
+ })
200
+ if (!res.ok) {
201
+ const text = await res.text()
202
+ logger.warn(
203
+ `Failed to upload asset ${assetPath}: ${res.status} ${text}`
204
+ )
205
+ } else {
206
+ logger.info(`Asset uploaded: ${assetPath}`)
207
+ }
208
+ } catch (err) {
209
+ logger.warn(`Network error uploading asset ${assetPath}:`, err)
210
+ }
211
+ }
212
+ }
213
+
214
+ async function uploadRecordings(
215
+ screenciDir: string,
216
+ projectName: string,
217
+ apiUrl: string,
218
+ secret: string,
219
+ specificEntry?: string
220
+ ): Promise<void> {
221
+ let entries: string[]
222
+ try {
223
+ entries = await readdir(screenciDir)
224
+ } catch {
225
+ logger.warn('No .screenci directory found, skipping upload')
226
+ return
227
+ }
228
+
229
+ if (specificEntry !== undefined) {
230
+ entries = entries.filter((e) => e === specificEntry)
231
+ }
232
+
233
+ for (const entry of entries) {
234
+ const dataJsonPath = resolve(screenciDir, entry, 'data.json')
235
+ if (!existsSync(dataJsonPath)) continue
236
+
237
+ let data: RecordingData
238
+ try {
239
+ const raw = await readFile(dataJsonPath, 'utf-8')
240
+ data = JSON.parse(raw) as RecordingData
241
+ } catch {
242
+ logger.warn(`Failed to read ${dataJsonPath}, skipping`)
243
+ continue
244
+ }
245
+
246
+ const videoName = data.metadata?.videoName ?? entry
247
+
248
+ logger.info(`Uploading "${videoName}"...`)
249
+ try {
250
+ // Step 1: register upload and get recordingId
251
+ const startResponse = await fetch(`${apiUrl}/cli/upload/start`, {
252
+ method: 'POST',
253
+ headers: {
254
+ 'Content-Type': 'application/json',
255
+ 'X-ScreenCI-Secret': secret,
256
+ },
257
+ body: JSON.stringify({ projectName, videoName, data }),
258
+ })
259
+ if (!startResponse.ok) {
260
+ const text = await startResponse.text()
261
+ logger.warn(
262
+ `Failed to start upload for "${videoName}": ${startResponse.status} ${text}`
263
+ )
264
+ continue
265
+ }
266
+ const { recordingId } = (await startResponse.json()) as {
267
+ recordingId: string
268
+ }
269
+
270
+ // Step 1b: upload asset files referenced in data.json
271
+ await uploadAssets(
272
+ data,
273
+ apiUrl,
274
+ secret,
275
+ recordingId,
276
+ resolve(screenciDir, '..')
277
+ )
278
+
279
+ // Step 2: stream the recording video file (if it exists)
280
+ const recordingPath = resolve(screenciDir, entry, 'recording.mp4')
281
+ if (existsSync(recordingPath)) {
282
+ const fileStat = await stat(recordingPath)
283
+ const stream = createReadStream(recordingPath)
284
+ const recordingResponse = await fetch(
285
+ `${apiUrl}/cli/upload/${recordingId}/recording`,
286
+ {
287
+ method: 'PUT',
288
+ headers: {
289
+ 'Content-Type': 'video/mp4',
290
+ 'Content-Length': String(fileStat.size),
291
+ 'X-ScreenCI-Secret': secret,
292
+ },
293
+ body: stream as unknown as BodyInit,
294
+ // @ts-expect-error Node.js fetch supports duplex for streaming
295
+ duplex: 'half',
296
+ }
297
+ )
298
+ if (!recordingResponse.ok) {
299
+ const text = await recordingResponse.text()
300
+ logger.warn(
301
+ `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}`
302
+ )
303
+ continue
304
+ }
305
+ }
306
+
307
+ logger.info(`Uploaded "${videoName}" successfully`)
308
+ } catch (err) {
309
+ logger.warn(`Network error uploading "${videoName}":`, err)
310
+ }
311
+ }
312
+ }
313
+
314
+ async function uploadLatest(configPath: string | undefined): Promise<void> {
315
+ const resolvedConfigPath = findScreenCIConfig(configPath)
316
+ if (!resolvedConfigPath) {
317
+ const errorMsg = configPath
318
+ ? `Error: Config file not found: ${configPath}`
319
+ : 'Error: screenci.config.ts not found in current directory'
320
+ logger.error(errorMsg)
321
+ process.exit(1)
322
+ }
323
+
324
+ let screenciConfig: ScreenCIConfig
325
+ try {
326
+ const configModule = await import(resolvedConfigPath)
327
+ screenciConfig = configModule.default as ScreenCIConfig
328
+ } catch (err) {
329
+ logger.error('Failed to load config:', err)
330
+ process.exit(1)
331
+ }
332
+
333
+ if (screenciConfig.envFile) {
334
+ const envFilePath = resolve(
335
+ dirname(resolvedConfigPath),
336
+ screenciConfig.envFile
337
+ )
338
+ try {
339
+ process.loadEnvFile(envFilePath)
340
+ } catch (err) {
341
+ logger.warn(`Failed to load env file ${envFilePath}:`, err)
342
+ }
343
+ }
344
+
345
+ const convexUrl = screenciConfig.apiUrl ?? process.env.SCREENCI_URL
346
+ if (!convexUrl) {
347
+ logger.error(
348
+ 'No API URL configured. Set apiUrl in screenci.config.ts or SCREENCI_URL env var.'
349
+ )
350
+ process.exit(1)
351
+ }
352
+
353
+ const secret = process.env.SCREENCI_SECRET
354
+ if (!secret) {
355
+ logger.error(
356
+ 'No secret configured. Set SCREENCI_SECRET in your .env file (get it from the API Key page in the dashboard).'
357
+ )
358
+ process.exit(1)
359
+ }
360
+
361
+ const configDir = dirname(resolvedConfigPath)
362
+ const screenciDir = resolve(configDir, '.screenci')
363
+
364
+ const latestEntry = await findLatestEntry(screenciDir)
365
+ if (!latestEntry) {
366
+ logger.warn('No recordings found in .screenci directory')
367
+ return
368
+ }
369
+
370
+ logger.info(`Uploading latest recording: "${latestEntry}"`)
371
+ await uploadRecordings(
372
+ screenciDir,
373
+ screenciConfig.projectName,
374
+ convexUrl,
375
+ secret,
376
+ latestEntry
377
+ )
378
+ }
379
+
380
+ function generateConfig(projectName: string): string {
381
+ return `import { defineConfig } from 'screenci'
382
+
383
+ export default defineConfig({
384
+ projectName: ${JSON.stringify(projectName)},
385
+ apiUrl: process.env.SCREENCI_URL ?? 'http://localhost:8787',
386
+ envFile: '.env',
387
+ videoDir: './videos',
388
+ forbidOnly: !!process.env.CI,
389
+ reporter: 'html',
390
+ use: {
391
+ trace: 'retain-on-failure',
392
+ sendTraces: true,
393
+ recordOptions: {
394
+ aspectRatio: '16:9',
395
+ quality: '1080p',
396
+ fps: 30,
397
+ },
398
+ },
399
+ projects: [
400
+ {
401
+ name: 'chromium',
402
+ },
403
+ ],
404
+ })
405
+ `
406
+ }
407
+
408
+ function generatePackageJson(
409
+ projectName: string,
410
+ localPackagePath?: string
411
+ ): string {
412
+ const npmName = projectName.toLowerCase().replace(/[^a-z0-9-]/g, '-')
413
+ const screenciVersion = localPackagePath
414
+ ? `file:${localPackagePath}`
415
+ : 'latest'
416
+ return (
417
+ JSON.stringify(
418
+ {
419
+ name: npmName,
420
+ version: '1.0.0',
421
+ description: '',
422
+ type: 'module',
423
+ scripts: {
424
+ record: 'screenci record',
425
+ 'upload-latest': 'screenci upload-latest',
426
+ dev: 'screenci dev',
427
+ },
428
+ dependencies: {
429
+ screenci: screenciVersion,
430
+ },
431
+ devDependencies: {
432
+ '@types/node': '^25.0.0',
433
+ tsx: '^4.21.0',
434
+ },
435
+ },
436
+ null,
437
+ 2
438
+ ) + '\n'
439
+ )
440
+ }
441
+
442
+ function generateDockerfile(): string {
443
+ return `FROM screenci
444
+
445
+ COPY package.json ./
446
+ COPY screenci.config.ts ./
447
+ COPY videos ./videos
448
+ `
449
+ }
450
+
451
+ function generateGitignore(): string {
452
+ return `/playwright-report/
453
+ .screenci
454
+ node_modules/
455
+ .env
456
+ `
457
+ }
458
+
459
+ function generateGithubAction(): string {
460
+ return `name: Record
461
+
462
+ on:
463
+ push:
464
+ branches: [main]
465
+ workflow_dispatch:
466
+
467
+ jobs:
468
+ record:
469
+ runs-on: ubuntu-latest
470
+ steps:
471
+ - name: Check SCREENCI_SECRET
472
+ env:
473
+ SCREENCI_SECRET: \${{ secrets.SCREENCI_SECRET }}
474
+ run: |
475
+ if [ -z "$SCREENCI_SECRET" ]; then
476
+ echo "::error::SCREENCI_SECRET is not set. Add it under Settings → Secrets and variables → Actions."
477
+ exit 1
478
+ fi
479
+
480
+ - uses: actions/checkout@v4
481
+
482
+ - name: Build Docker image
483
+ run: docker build -t screenci-project .
484
+
485
+ - name: Record
486
+ env:
487
+ SCREENCI_SECRET: \${{ secrets.SCREENCI_SECRET }}
488
+ run: |
489
+ docker run --rm \\
490
+ -e SCREENCI_SECRET \\
491
+ -e SCREENCI_IN_CONTAINER=true \\
492
+ -e SCREENCI_RECORD=true \\
493
+ screenci-project \\
494
+ npm run record
495
+ `
496
+ }
497
+
498
+ function openBrowser(url: string): void {
499
+ const cmd =
500
+ process.platform === 'darwin'
501
+ ? 'open'
502
+ : process.platform === 'win32'
503
+ ? 'start'
504
+ : 'xdg-open'
505
+ spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref()
506
+ }
507
+
508
+ async function performBrowserLogin(appUrl: string): Promise<string> {
509
+ return new Promise<string>((resolve, reject) => {
510
+ const server = createServer((req, res) => {
511
+ try {
512
+ const reqUrl = new URL(req.url ?? '/', 'http://localhost')
513
+ const secret = reqUrl.searchParams.get('secret')
514
+
515
+ if (secret) {
516
+ res.writeHead(200, { 'Content-Type': 'text/html' })
517
+ res.end(
518
+ '<html><body style="font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><p style="font-size:1.2rem">Authentication successful! You can close this tab.</p></body></html>'
519
+ )
520
+ server.close()
521
+ resolve(secret)
522
+ } else {
523
+ res.writeHead(400, { 'Content-Type': 'text/html' })
524
+ res.end(
525
+ '<html><body style="font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><p style="color:red;font-size:1.2rem">Authentication failed: no secret received. Please try again.</p></body></html>'
526
+ )
527
+ server.close()
528
+ reject(new Error('No secret received in callback'))
529
+ }
530
+ } catch (err) {
531
+ res.writeHead(500)
532
+ res.end('Internal error')
533
+ server.close()
534
+ reject(err)
535
+ }
536
+ })
537
+
538
+ server.listen(0, '127.0.0.1', () => {
539
+ const port = (server.address() as AddressInfo).port
540
+ const callbackUrl = `http://localhost:${port}/callback`
541
+ const loginUrl = `${appUrl}/cli-auth?callback=${encodeURIComponent(callbackUrl)}`
542
+
543
+ logger.info('Opening browser for authentication...')
544
+ logger.info(`If the browser does not open automatically, visit:`)
545
+ logger.info(` ${loginUrl}`)
546
+
547
+ openBrowser(loginUrl)
548
+ })
549
+
550
+ const timeout = setTimeout(
551
+ () => {
552
+ server.close()
553
+ reject(new Error('Authentication timed out after 5 minutes'))
554
+ },
555
+ 5 * 60 * 1000
556
+ )
557
+
558
+ server.on('close', () => clearTimeout(timeout))
559
+ })
560
+ }
561
+
562
+ function generateExampleVideo(): string {
563
+ return `import { video } from 'screenci'
564
+
565
+ video('Example video', async ({ page }) => {
566
+ await page.goto('https://example.com')
567
+ await page.waitForTimeout(3000)
568
+ })
569
+ `
570
+ }
571
+
572
+ async function promptLine(question: string): Promise<string> {
573
+ const rl = createInterface({
574
+ input: process.stdin,
575
+ output: process.stdout,
576
+ })
577
+ try {
578
+ const answer = await rl.question(question)
579
+ return answer.trim()
580
+ } finally {
581
+ rl.close()
582
+ }
583
+ }
584
+
585
+ async function promptProjectName(): Promise<string> {
586
+ return promptLine('Project name: ')
587
+ }
588
+
589
+ function toKebabCase(name: string): string {
590
+ return name
591
+ .toLowerCase()
592
+ .replace(/\s+/g, '-')
593
+ .replace(/[^a-z0-9-]/g, '-')
594
+ .replace(/-+/g, '-')
595
+ .replace(/^-|-$/g, '')
596
+ }
597
+
598
+ async function runInitAuth(): Promise<void> {
599
+ const devPort = process.env.DEV_PORT
600
+ const appUrl =
601
+ process.env.SCREENCI_APP_URL ??
602
+ (devPort ? `http://localhost:${devPort}` : 'https://app.screenci.com')
603
+ try {
604
+ const secret = await performBrowserLogin(appUrl)
605
+ await writeFile(
606
+ resolve(process.cwd(), '.env'),
607
+ `SCREENCI_SECRET=${secret}\n`
608
+ )
609
+ logger.info('API key saved to .env')
610
+ } catch (err) {
611
+ const msg = err instanceof Error ? err.message : String(err)
612
+ logger.warn(`Authentication failed: ${msg}`)
613
+ logger.info(
614
+ 'You can add SCREENCI_SECRET manually to .env later (get it from the API Key page in the dashboard).'
615
+ )
616
+ }
617
+ }
618
+
619
+ function checkNodeVersion(): void {
620
+ const [major] = process.versions.node.split('.').map(Number)
621
+ if (major === undefined || major < 18) {
622
+ logger.error(
623
+ `Error: Node.js 18 or higher is required (current: v${process.versions.node})`
624
+ )
625
+ process.exit(1)
626
+ }
627
+ }
628
+
629
+ async function runInit(
630
+ projectNameArg?: string,
631
+ localPackagePath?: string
632
+ ): Promise<void> {
633
+ checkNodeVersion()
634
+
635
+ let projectName = projectNameArg?.trim()
636
+
637
+ if (!projectName) {
638
+ projectName = await promptProjectName()
639
+ }
640
+
641
+ if (!projectName) {
642
+ logger.error('Error: Project name is required')
643
+ process.exit(1)
644
+ }
645
+
646
+ const dirName = toKebabCase(projectName)
647
+ const projectDir = resolve(process.cwd(), dirName)
648
+
649
+ if (existsSync(projectDir)) {
650
+ logger.error(`Error: Directory "${dirName}" already exists`)
651
+ process.exit(1)
652
+ }
653
+
654
+ await mkdir(resolve(projectDir, 'videos'), { recursive: true })
655
+ await mkdir(resolve(projectDir, '.github', 'workflows'), { recursive: true })
656
+ await writeFile(
657
+ resolve(projectDir, 'screenci.config.ts'),
658
+ generateConfig(projectName)
659
+ )
660
+ await writeFile(
661
+ resolve(projectDir, 'package.json'),
662
+ generatePackageJson(dirName, localPackagePath)
663
+ )
664
+ await writeFile(resolve(projectDir, 'Dockerfile'), generateDockerfile())
665
+ await writeFile(resolve(projectDir, '.gitignore'), generateGitignore())
666
+ await writeFile(
667
+ resolve(projectDir, 'videos', 'example.video.ts'),
668
+ generateExampleVideo()
669
+ )
670
+ await writeFile(
671
+ resolve(projectDir, '.github', 'workflows', 'record.yml'),
672
+ generateGithubAction()
673
+ )
674
+
675
+ logger.info(`Initialized screenci project "${projectName}" in ${dirName}/`)
676
+ logger.info('Files created:')
677
+ logger.info(' screenci.config.ts')
678
+ logger.info(' package.json')
679
+ logger.info(' Dockerfile')
680
+ logger.info(' .gitignore')
681
+ logger.info(' videos/example.video.ts')
682
+ logger.info(' .github/workflows/record.yml')
683
+ logger.info('')
684
+
685
+ logger.info('Running npm install...')
686
+ await spawnInherited('npm', ['install', '--prefix', projectDir])
687
+
688
+ logger.info('')
689
+ logger.info('Next steps:')
690
+ logger.info(` cd ${dirName}`)
691
+ logger.info(' screenci record')
692
+ }
693
+
694
+ export async function main() {
695
+ const args = process.argv.slice(2)
696
+ const { command, configPath, noContainer, otherArgs } = parseArgs(args)
697
+
698
+ switch (command) {
699
+ case 'record': {
700
+ const useContainer =
701
+ !noContainer && process.env.SCREENCI_IN_CONTAINER !== 'true'
702
+
703
+ // Validate early so we don't build the container unnecessarily
704
+ if (useContainer) {
705
+ validateArgs(otherArgs)
706
+ }
707
+
708
+ // On the host, acquire secret before recording if missing
709
+ if (process.env.SCREENCI_IN_CONTAINER !== 'true') {
710
+ const resolvedConfigForSecret = findScreenCIConfig(configPath)
711
+ if (resolvedConfigForSecret) {
712
+ let envFilePath: string | null = null
713
+ try {
714
+ const configModule = await import(resolvedConfigForSecret)
715
+ const screenciConfig = configModule.default as ScreenCIConfig
716
+ envFilePath = screenciConfig.envFile
717
+ ? resolve(
718
+ dirname(resolvedConfigForSecret),
719
+ screenciConfig.envFile
720
+ )
721
+ : null
722
+ if (envFilePath) {
723
+ try {
724
+ process.loadEnvFile(envFilePath)
725
+ } catch {
726
+ // env file may not exist yet
727
+ }
728
+ }
729
+ } catch (err) {
730
+ if (!process.env.SCREENCI_SECRET) {
731
+ const msg = err instanceof Error ? err.message : String(err)
732
+ logger.error(`Failed to acquire secret: ${msg}`)
733
+ process.exit(1)
734
+ }
735
+ // Config import failed but SCREENCI_SECRET is already in env — continue
736
+ }
737
+
738
+ if (!process.env.SCREENCI_SECRET) {
739
+ logger.info(
740
+ 'SCREENCI_SECRET not found. Opening browser to sign in and select a plan...'
741
+ )
742
+ const devPort = process.env.DEV_PORT
743
+ const appUrl =
744
+ process.env.SCREENCI_APP_URL ??
745
+ (devPort
746
+ ? `http://localhost:${devPort}`
747
+ : 'https://app.screenci.com')
748
+ const secret = await performBrowserLogin(appUrl)
749
+ const savePath =
750
+ envFilePath ?? resolve(dirname(resolvedConfigForSecret), '.env')
751
+ await writeFile(savePath, `SCREENCI_SECRET=${secret}\n`)
752
+ process.env.SCREENCI_SECRET = secret
753
+ logger.info('API key saved.')
754
+ }
755
+ }
756
+ }
757
+
758
+ if (useContainer) {
759
+ await runWithContainer(otherArgs, configPath)
760
+ } else {
761
+ await run(command, otherArgs, configPath)
762
+ }
763
+
764
+ // Upload only from the host, not from inside the container
765
+ if (process.env.SCREENCI_IN_CONTAINER === 'true') break
766
+
767
+ // After recording, upload results to Convex if configured
768
+ const resolvedConfigPath = findScreenCIConfig(configPath)
769
+ if (resolvedConfigPath) {
770
+ try {
771
+ const configModule = await import(resolvedConfigPath)
772
+ const screenciConfig = configModule.default as ScreenCIConfig
773
+ if (screenciConfig.envFile) {
774
+ const envFilePath = resolve(
775
+ dirname(resolvedConfigPath),
776
+ screenciConfig.envFile
777
+ )
778
+ try {
779
+ process.loadEnvFile(envFilePath)
780
+ } catch (err) {
781
+ logger.warn(`Failed to load env file ${envFilePath}:`, err)
782
+ }
783
+ }
784
+ const convexUrl = screenciConfig.apiUrl ?? process.env.SCREENCI_URL
785
+ if (!convexUrl) {
786
+ logger.info(
787
+ 'No API URL configured, skipping upload. Set apiUrl in screenci.config.ts or SCREENCI_URL env var.'
788
+ )
789
+ break
790
+ }
791
+ const secret = process.env.SCREENCI_SECRET
792
+ if (!secret) {
793
+ logger.info(
794
+ 'No secret configured, skipping upload. Set SCREENCI_SECRET in your .env file.'
795
+ )
796
+ break
797
+ }
798
+ const configDir = dirname(resolvedConfigPath)
799
+ const screenciDir = resolve(configDir, '.screenci')
800
+ await uploadRecordings(
801
+ screenciDir,
802
+ screenciConfig.projectName,
803
+ convexUrl,
804
+ secret
805
+ )
806
+ } catch (err) {
807
+ logger.warn('Failed to load config for upload:', err)
808
+ }
809
+ }
810
+ break
811
+ }
812
+ case 'dev':
813
+ await run(command, otherArgs, configPath)
814
+ break
815
+ case 'upload-latest':
816
+ await uploadLatest(configPath)
817
+ break
818
+ case 'init': {
819
+ if (otherArgs[0] === 'auth') {
820
+ await runInitAuth()
821
+ } else {
822
+ const localFlagIndex = otherArgs.indexOf('--local')
823
+ let localPackagePath: string | undefined
824
+ let initArgs = otherArgs
825
+ if (localFlagIndex !== -1) {
826
+ const cliDir = dirname(fileURLToPath(import.meta.url))
827
+ // cli.ts is at package root; dist/cli.js is one level down
828
+ localPackagePath = existsSync(resolve(cliDir, 'package.json'))
829
+ ? cliDir
830
+ : resolve(cliDir, '..')
831
+ initArgs = otherArgs.filter((_, i) => i !== localFlagIndex)
832
+ }
833
+ await runInit(initArgs[0], localPackagePath)
834
+ }
835
+ break
836
+ }
837
+ default:
838
+ logger.error(`Unknown command: ${command}`)
839
+ logger.error('Available commands: record, dev, upload-latest, init')
840
+ process.exit(1)
841
+ }
842
+ }
843
+
844
+ function validateArgs(args: string[]): void {
845
+ const disallowedFlags = ['--fully-parallel', '--workers', '-j', '--retries']
846
+
847
+ for (const arg of args) {
848
+ if (arg === undefined) continue
849
+
850
+ // Check if it's a disallowed flag
851
+ if (disallowedFlags.includes(arg)) {
852
+ throw new Error(
853
+ `Flag "${arg}" is not supported by screenci. ` +
854
+ 'screenci enforces sequential test execution with a single worker and no retries for proper video recording.'
855
+ )
856
+ }
857
+
858
+ // Check if it's a --workers=N, -j=N, or --retries=N format
859
+ if (
860
+ arg.startsWith('--workers=') ||
861
+ arg.startsWith('-j=') ||
862
+ arg.startsWith('--retries=')
863
+ ) {
864
+ throw new Error(
865
+ `Flag "${arg}" is not supported by screenci. ` +
866
+ 'screenci enforces sequential test execution with a single worker and no retries for proper video recording.'
867
+ )
868
+ }
869
+ }
870
+ }
871
+
872
+ function spawnInherited(cmd: string, args: string[]): Promise<void> {
873
+ const child = spawn(cmd, args, { stdio: 'inherit' })
874
+
875
+ const forwardSignal = (signal: NodeJS.Signals) => {
876
+ logger.info(`Received ${signal}, stopping...`)
877
+ if (!child.killed) {
878
+ child.kill(signal)
879
+ }
880
+ const forceKill = setTimeout(() => {
881
+ if (child.exitCode === null) {
882
+ logger.info('Forcing kill after timeout...')
883
+ child.kill('SIGKILL')
884
+ }
885
+ }, 3000)
886
+ forceKill.unref()
887
+ }
888
+
889
+ process.on('SIGINT', forwardSignal)
890
+ process.on('SIGTERM', forwardSignal)
891
+
892
+ return new Promise<void>((resolve, reject) => {
893
+ child.on('close', (code) => {
894
+ process.off('SIGINT', forwardSignal)
895
+ process.off('SIGTERM', forwardSignal)
896
+ if (code === 0) {
897
+ resolve()
898
+ } else {
899
+ reject(new Error(`${cmd} exited with code ${code}`))
900
+ }
901
+ })
902
+
903
+ child.on('error', (err) => {
904
+ process.off('SIGINT', forwardSignal)
905
+ process.off('SIGTERM', forwardSignal)
906
+ reject(err)
907
+ })
908
+ })
909
+ }
910
+
911
+ export function detectContainerRuntime(): string {
912
+ for (const runtime of ['podman', 'docker']) {
913
+ const result = spawnSync(runtime, ['--version'], { stdio: 'ignore' })
914
+ if (result.status === 0 && result.error === undefined) {
915
+ return runtime
916
+ }
917
+ }
918
+ logger.error('Error: Neither podman nor docker found.')
919
+ logger.error(
920
+ 'Please install podman (recommended) or docker to use screenci record.'
921
+ )
922
+ logger.error(' podman: https://podman.io/docs/installation')
923
+ logger.error(' docker: https://docs.docker.com/get-docker/')
924
+ process.exit(1)
925
+ }
926
+
927
+ async function runWithContainer(
928
+ additionalArgs: string[],
929
+ customConfigPath?: string
930
+ ) {
931
+ const configPath = findScreenCIConfig(customConfigPath)
932
+
933
+ if (!configPath) {
934
+ const errorMsg = customConfigPath
935
+ ? `Error: Config file not found: ${customConfigPath}`
936
+ : 'Error: screenci.config.ts not found in current directory'
937
+ logger.error(errorMsg)
938
+ process.exit(1)
939
+ }
940
+
941
+ const configDir = dirname(configPath)
942
+ const dockerfilePath = resolve(configDir, 'Dockerfile')
943
+
944
+ if (!existsSync(dockerfilePath)) {
945
+ logger.error(`Error: Dockerfile not found at ${dockerfilePath}`)
946
+ logger.error(
947
+ 'Container mode requires a Dockerfile next to screenci.config.ts'
948
+ )
949
+ process.exit(1)
950
+ }
951
+
952
+ const repoRoot = findRepoRoot(configDir)
953
+ if (!repoRoot) {
954
+ logger.error(
955
+ 'Error: Could not find repository root (.git or pnpm-workspace.yaml)'
956
+ )
957
+ process.exit(1)
958
+ }
959
+
960
+ const containerRuntime = detectContainerRuntime()
961
+
962
+ const cliDir = dirname(fileURLToPath(import.meta.url))
963
+ const screenciDockerfilePath = resolve(cliDir, 'Dockerfile')
964
+
965
+ logger.info(`Building container image with ${containerRuntime}...`)
966
+ logger.info(`Using Dockerfile: ${screenciDockerfilePath}`)
967
+ logger.info(`Build context: ${repoRoot}`)
968
+ await spawnInherited(containerRuntime, [
969
+ 'build',
970
+ '-f',
971
+ screenciDockerfilePath,
972
+ '-t',
973
+ 'screenci',
974
+ repoRoot,
975
+ ])
976
+
977
+ logger.info(`Using Dockerfile: ${dockerfilePath}`)
978
+ logger.info(`Build context: ${configDir}`)
979
+ await spawnInherited(containerRuntime, [
980
+ 'build',
981
+ '-f',
982
+ dockerfilePath,
983
+ '-t',
984
+ 'screenci',
985
+ configDir,
986
+ ])
987
+
988
+ clearDirectory(resolve(configDir, '.screenci'))
989
+
990
+ logger.info('Running recording in container...')
991
+ await spawnInherited(containerRuntime, [
992
+ 'run',
993
+ '--rm',
994
+ '-e',
995
+ 'SCREENCI_IN_CONTAINER=true',
996
+ '-e',
997
+ 'SCREENCI_RECORD=true',
998
+ '-v',
999
+ `${configDir}/.screenci:/app/.screenci`,
1000
+ '-v',
1001
+ `${configPath}:/app/screenci.config.ts`,
1002
+ '-v',
1003
+ `${configDir}/videos:/app/videos`,
1004
+ 'screenci',
1005
+ 'screenci',
1006
+ 'record',
1007
+ ...additionalArgs,
1008
+ ])
1009
+ }
1010
+
1011
+ async function run(
1012
+ command: string,
1013
+ additionalArgs: string[],
1014
+ customConfigPath?: string
1015
+ ) {
1016
+ const configPath = findScreenCIConfig(customConfigPath)
1017
+
1018
+ if (!configPath) {
1019
+ const errorMsg = customConfigPath
1020
+ ? `Error: Config file not found: ${customConfigPath}`
1021
+ : 'Error: screenci.config.ts not found in current directory'
1022
+ logger.error(errorMsg)
1023
+ process.exit(1)
1024
+ }
1025
+
1026
+ // Only validate args for record command (dev allows parallel execution)
1027
+ if (command === 'record') {
1028
+ validateArgs(additionalArgs)
1029
+ const screenciDir = resolve(dirname(configPath), '.screenci')
1030
+ clearDirectory(screenciDir)
1031
+ }
1032
+
1033
+ // For dev command: use --ui unless --headed is specified
1034
+ const isHeaded = additionalArgs.includes('--headed')
1035
+ const shouldUseUI = command === 'dev' && !isHeaded
1036
+
1037
+ const mode =
1038
+ command === 'dev' ? (isHeaded ? 'headed mode' : 'UI mode') : 'recorder'
1039
+ logger.info(`Running ScreenCI ${mode} with npx...`)
1040
+ logger.info(`Using config: ${configPath}`)
1041
+
1042
+ const playwrightArgs = [
1043
+ 'playwright',
1044
+ 'test',
1045
+ '--config',
1046
+ configPath,
1047
+ ...(shouldUseUI ? ['--ui'] : []),
1048
+ ...additionalArgs,
1049
+ ]
1050
+
1051
+ const child = spawn('npx', playwrightArgs, {
1052
+ stdio: 'inherit',
1053
+ env: {
1054
+ ...process.env,
1055
+ // Enable recording only for record command
1056
+ ...(command === 'record' ? { SCREENCI_RECORD: 'true' } : {}),
1057
+ },
1058
+ })
1059
+
1060
+ const forwardSignal = (signal: NodeJS.Signals) => {
1061
+ logger.info(`Received ${signal}, stopping recording...`)
1062
+ if (!child.killed) {
1063
+ child.kill(signal)
1064
+ }
1065
+ // Force-kill after 3 s if the child hasn't actually exited yet.
1066
+ // child.killed becomes true as soon as we send the signal, so we check
1067
+ // child.exitCode instead — it stays null until the process truly exits.
1068
+ // unref() so the timer doesn't keep the process alive on its own.
1069
+ const forceKill = setTimeout(() => {
1070
+ if (child.exitCode === null) {
1071
+ logger.info('Forcing kill after timeout...')
1072
+ child.kill('SIGKILL')
1073
+ }
1074
+ }, 3000)
1075
+ forceKill.unref()
1076
+ }
1077
+
1078
+ process.on('SIGINT', forwardSignal)
1079
+ process.on('SIGTERM', forwardSignal)
1080
+
1081
+ return new Promise<void>((resolve, reject) => {
1082
+ child.on('close', (code) => {
1083
+ process.off('SIGINT', forwardSignal)
1084
+ process.off('SIGTERM', forwardSignal)
1085
+ if (code === 0) {
1086
+ resolve()
1087
+ } else {
1088
+ reject(new Error(`Playwright exited with code ${code}`))
1089
+ }
1090
+ })
1091
+
1092
+ child.on('error', (err) => {
1093
+ reject(err)
1094
+ })
1095
+ })
1096
+ }
1097
+
1098
+ // Only run if this file is being executed directly
1099
+ // Check if this module is the main module (handles symlinks properly)
1100
+ const currentFile = fileURLToPath(import.meta.url)
1101
+ const mainFile = process.argv[1] ? realpathSync(process.argv[1]) : null
1102
+
1103
+ if (
1104
+ mainFile &&
1105
+ (currentFile === mainFile || currentFile === realpathSync(mainFile))
1106
+ ) {
1107
+ main().catch((error) => {
1108
+ logger.error('Error:', error.message)
1109
+ process.exit(1)
1110
+ })
1111
+ }