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.
- package/README.md +227 -0
- package/cli.ts +1111 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +896 -0
- package/dist/cli.js.map +1 -0
- package/dist/e2e/instrument.e2e.d.ts +2 -0
- package/dist/e2e/instrument.e2e.d.ts.map +1 -0
- package/dist/e2e/instrument.e2e.js +661 -0
- package/dist/e2e/instrument.e2e.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/playwright.config.d.ts +3 -0
- package/dist/playwright.config.d.ts.map +1 -0
- package/dist/playwright.config.js +21 -0
- package/dist/playwright.config.js.map +1 -0
- package/dist/reporter.d.ts +9 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/reporter.js +49 -0
- package/dist/reporter.js.map +1 -0
- package/dist/src/asset.d.ts +90 -0
- package/dist/src/asset.d.ts.map +1 -0
- package/dist/src/asset.js +74 -0
- package/dist/src/asset.js.map +1 -0
- package/dist/src/autoZoom.d.ts +40 -0
- package/dist/src/autoZoom.d.ts.map +1 -0
- package/dist/src/autoZoom.js +88 -0
- package/dist/src/autoZoom.js.map +1 -0
- package/dist/src/caption.d.ts +152 -0
- package/dist/src/caption.d.ts.map +1 -0
- package/dist/src/caption.js +240 -0
- package/dist/src/caption.js.map +1 -0
- package/dist/src/caption.test-d.d.ts +2 -0
- package/dist/src/caption.test-d.d.ts.map +1 -0
- package/dist/src/caption.test-d.js +50 -0
- package/dist/src/caption.test-d.js.map +1 -0
- package/dist/src/config.d.ts +42 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +147 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/defaults.d.ts +63 -0
- package/dist/src/defaults.d.ts.map +1 -0
- package/dist/src/defaults.js +66 -0
- package/dist/src/defaults.js.map +1 -0
- package/dist/src/dimensions.d.ts +29 -0
- package/dist/src/dimensions.d.ts.map +1 -0
- package/dist/src/dimensions.js +47 -0
- package/dist/src/dimensions.js.map +1 -0
- package/dist/src/events.d.ts +203 -0
- package/dist/src/events.d.ts.map +1 -0
- package/dist/src/events.js +227 -0
- package/dist/src/events.js.map +1 -0
- package/dist/src/hide.d.ts +27 -0
- package/dist/src/hide.d.ts.map +1 -0
- package/dist/src/hide.js +49 -0
- package/dist/src/hide.js.map +1 -0
- package/dist/src/instrument.d.ts +15 -0
- package/dist/src/instrument.d.ts.map +1 -0
- package/dist/src/instrument.js +910 -0
- package/dist/src/instrument.js.map +1 -0
- package/dist/src/logger.d.ts +7 -0
- package/dist/src/logger.d.ts.map +1 -0
- package/dist/src/logger.js +13 -0
- package/dist/src/logger.js.map +1 -0
- package/dist/src/reporter.d.ts +9 -0
- package/dist/src/reporter.d.ts.map +1 -0
- package/dist/src/reporter.js +50 -0
- package/dist/src/reporter.js.map +1 -0
- package/dist/src/sanitize.d.ts +5 -0
- package/dist/src/sanitize.d.ts.map +1 -0
- package/dist/src/sanitize.js +11 -0
- package/dist/src/sanitize.js.map +1 -0
- package/dist/src/types.d.ts +544 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/video.d.ts +138 -0
- package/dist/src/video.d.ts.map +1 -0
- package/dist/src/video.js +415 -0
- package/dist/src/video.js.map +1 -0
- package/dist/src/voices.d.ts +60 -0
- package/dist/src/voices.d.ts.map +1 -0
- package/dist/src/voices.js +42 -0
- package/dist/src/voices.js.map +1 -0
- package/dist/src/xvfb.d.ts +22 -0
- package/dist/src/xvfb.d.ts.map +1 -0
- package/dist/src/xvfb.js +87 -0
- package/dist/src/xvfb.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +45 -4
- package/bin/index.js +0 -3
- 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
|
+
}
|