Files
screenci__screenci/cli.ts
T
2026-06-12 20:11:08 +03:00

2771 lines
77 KiB
TypeScript

import { spawn } from 'child_process'
import type { ChildProcess } from 'child_process'
import { createReadStream } from 'fs'
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
rmSync,
} from 'fs'
import { createHash, randomUUID } from 'crypto'
import { createRequire } from 'module'
import { appendFile, readdir, readFile, stat, writeFile } from 'fs/promises'
import { delimiter, dirname, relative as pathRelative, resolve } from 'path'
import { fileURLToPath, pathToFileURL } from 'url'
import { Command, CommanderError } from 'commander'
import pc from 'picocolors'
import { logger } from './src/logger.js'
import {
determinePackageManager,
parsePackageManager,
runInit,
} from './src/init.js'
import type {
RecordingCustomVoiceRef,
RecordingData,
VideoCueTranslationFile,
} from './src/events.js'
import {
SCREENCI_DISABLE_RECORDING_TIMINGS_ENV,
SCREENCI_MOCK_RECORD_ENV,
} from './src/runtimeMode.js'
import { DEFAULT_RECORD_UPLOAD_POLICY } from './src/defaults.js'
import type { VoiceKey } from './src/voices.js'
import type { RecordUploadPolicy, ScreenCIConfig } from './src/types.js'
import {
findDuplicateTitles,
formatDuplicateTitlesMessage,
} from './src/titleValidation.js'
const SCREENCI_MOCK_RECORD_DOCS_URL =
'https://screenci.com/docs/reference/cli/#--mock-record'
const SCREENCI_RECORD_DOCS_URL =
'https://screenci.com/docs/reference/cli/#screenci-record'
const SCREENCI_ENVIRONMENT_VARIABLE = 'SCREENCI_ENVIRONMENT'
const SCREENCI_ENVIRONMENT_OPTION_VALUES = ['local', 'dev', 'prod'] as const
const SCREENCI_PRODUCTION_BACKEND_URL = 'https://api.screenci.com'
const SCREENCI_PRODUCTION_FRONTEND_URL = 'https://app.screenci.com'
const SCREENCI_DEVELOPMENT_BACKEND_URL = 'https://dev.api.screenci.com'
const SCREENCI_DEVELOPMENT_FRONTEND_URL = 'https://dev.app.screenci.com'
const SCREENCI_LINK_SESSION_FILE = 'link-session.json'
const SCREENCI_LINK_SESSION_POLL_INTERVAL_MS = 2_000
const require = createRequire(import.meta.url)
type ScreenCIEnvironment = (typeof SCREENCI_ENVIRONMENT_OPTION_VALUES)[number]
type ProjectInfoVideo = {
name: string
id: string
isPublic: boolean
videoURL?: string
thumbnailURL?: string
subtitlesURL?: string
}
type ProjectInfoResponse = {
projectName: string
videos: ProjectInfoVideo[]
}
type PlaywrightListReportSuite = {
title?: string
specs?: Array<{ title: string }>
suites?: PlaywrightListReportSuite[]
}
type PlaywrightListReport = {
suites?: PlaywrightListReportSuite[]
errors?: Array<{
message?: string
snippet?: string
}>
}
type LinkSessionStatus =
| 'pending'
| 'completed'
| 'consumed'
| 'expired'
| 'invalid'
type PersistedLinkSessionSpec = {
token: string
appUrl: string
pollUrl: string
createdAt: string
expiresAt: string
environment: ScreenCIEnvironment
resolvedConfigPath?: string
envFilePath: string
}
function parseScreenCIEnvironment(
value: string | undefined
): ScreenCIEnvironment | undefined {
if (value === undefined) return undefined
if (
SCREENCI_ENVIRONMENT_OPTION_VALUES.includes(value as ScreenCIEnvironment)
) {
return value as ScreenCIEnvironment
}
throw new Error(
`Invalid ${SCREENCI_ENVIRONMENT_VARIABLE} "${value}". Expected one of: ${SCREENCI_ENVIRONMENT_OPTION_VALUES.join(', ')}`
)
}
function getScreenCIEnvironment(): ScreenCIEnvironment {
const parsed = parseScreenCIEnvironment(
process.env[SCREENCI_ENVIRONMENT_VARIABLE]
)
return parsed ?? 'prod'
}
export function collectPlaywrightListTitles(
suites: readonly PlaywrightListReportSuite[]
): string[] {
const titles: string[] = []
const visitSuite = (suite: PlaywrightListReportSuite) => {
for (const spec of suite.specs ?? []) {
titles.push(spec.title)
}
for (const child of suite.suites ?? []) {
visitSuite(child)
}
}
for (const suite of suites) {
visitSuite(suite)
}
return titles
}
function parsePlaywrightListReport(stdout: string): PlaywrightListReport {
return JSON.parse(stdout) as PlaywrightListReport
}
function extractPlaywrightDiscoveryError(output: string): string | null {
try {
const report = parsePlaywrightListReport(output)
const firstError = report.errors?.find(
(entry) =>
typeof entry.message === 'string' && entry.message.trim() !== ''
)
if (!firstError?.message) {
return null
}
const message = firstError.message.trim()
const snippet = firstError.snippet?.trim()
return snippet ? `${message}\n\n${snippet}` : message
} catch {
return null
}
}
function logScreenCISecretGuide(): void {
logger.info(`Guide: ${pc.cyan(SCREENCI_RECORD_DOCS_URL)}`)
}
function getSuggestedScreenciCommand(command: 'record' | 'test'): string {
const pm = determinePackageManager()
if (pm === 'pnpm') return `pnpm exec screenci ${command}`
if (pm === 'yarn') return `yarn screenci ${command}`
return `npx screenci ${command}`
}
async function collectDiscoveredTestTitles(
configPath: string,
additionalArgs: string[],
env: NodeJS.ProcessEnv
): Promise<string[]> {
const listArgs = [
'test',
'--config',
configPath,
...additionalArgs,
'--list',
'--reporter=json',
]
const spawnSpec = resolvePlaywrightSpawnSpec(listArgs, dirname(configPath))
return await new Promise<string[]>((resolve, reject) => {
const child = spawn(spawnSpec.command, spawnSpec.args, {
stdio: ['inherit', 'pipe', 'pipe'],
...(spawnSpec.shell !== undefined ? { shell: spawnSpec.shell } : {}),
...(spawnSpec.windowsVerbatimArguments !== undefined
? {
windowsVerbatimArguments: spawnSpec.windowsVerbatimArguments,
}
: {}),
env,
})
const childSignals = forwardChildSignals(
child,
'screenci title validation',
{
killTree: process.platform !== 'win32',
exitParentOnForward: true,
}
)
let stdout = ''
let stderr = ''
child.stdout?.setEncoding?.('utf8')
child.stderr?.setEncoding?.('utf8')
child.stdout?.on('data', (chunk) => {
stdout += chunk
})
child.stderr?.on('data', (chunk) => {
stderr += chunk
})
child.on('close', (code, signal) => {
const forwardedSignal = childSignals.getForwardedSignal()
childSignals.cleanup()
if (forwardedSignal) {
process.kill(process.pid, forwardedSignal)
return
}
if (signal) {
process.kill(process.pid, signal)
return
}
if (code !== 0) {
if (stderr.trim() === '' && stdout.trim() === '') {
resolve([])
return
}
const parsedDiscoveryError =
stderr.trim() === '' ? extractPlaywrightDiscoveryError(stdout) : null
reject(
new Error(
stderr.trim() ||
parsedDiscoveryError ||
stdout.trim() ||
'Playwright test discovery failed'
)
)
return
}
try {
if (stdout.trim() === '') {
resolve([])
return
}
const report = parsePlaywrightListReport(stdout)
resolve(collectPlaywrightListTitles(report.suites ?? []))
} catch (error) {
reject(error)
}
})
child.on('error', (err) => {
childSignals.cleanup()
reject(err)
})
})
}
async function validateUniqueDiscoveredTestTitles(
configPath: string,
additionalArgs: string[],
env: NodeJS.ProcessEnv
): Promise<void> {
const titles = await collectDiscoveredTestTitles(
configPath,
additionalArgs,
env
)
const duplicates = findDuplicateTitles(titles)
if (duplicates.length > 0) {
throw new Error(formatDuplicateTitlesMessage(duplicates))
}
}
function resolveRecordingFileCandidates(
filePath: string,
configDir: string,
sourceFilePath?: string
): string[] {
const sourceFileCandidate =
typeof sourceFilePath === 'string'
? resolve(configDir, dirname(sourceFilePath), filePath)
: null
return [
filePath,
...(sourceFileCandidate ? [sourceFileCandidate] : []),
resolve(configDir, 'videos', filePath),
resolve(configDir, pathRelative('/app', filePath)),
]
}
async function readRecordingFile(
filePath: string,
configDir: string,
sourceFilePath?: string
): Promise<{ buffer: Buffer; resolvedPath: string } | null> {
for (const candidate of resolveRecordingFileCandidates(
filePath,
configDir,
sourceFilePath
)) {
try {
return { buffer: await readFile(candidate), resolvedPath: candidate }
} catch {
// try next candidate
}
}
return null
}
function contentTypeForPath(filePath: string): string {
const ext = filePath.split('.').pop()?.toLowerCase() ?? 'bin'
const contentTypeMap: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
mp4: 'video/mp4',
webm: 'video/webm',
mp3: 'audio/mpeg',
svg: 'image/svg+xml',
}
return contentTypeMap[ext] ?? 'application/octet-stream'
}
type CustomVoiceRefLike = { assetHash: string; assetPath: string }
type PreparedUploadAsset = {
fileHash: string
path: string
size: number
name?: string
fileBuffer?: Buffer
contentType?: string
}
type UploadCandidate = {
entry: string
videoName: string
data: RecordingData
preparedUploadAssets: PreparedUploadAsset[]
}
type UploadJobResult = {
projectId: string | null
hadFailure: boolean
videoName: string
failureMessage?: string
recordId: string
}
type UploadProgressStatus = 'success' | 'failure' | 'cancelled'
class UploadAssetError extends Error {
constructor(message: string) {
super(message)
this.name = 'UploadAssetError'
}
}
class UploadCancelledError extends Error {
constructor(message = 'Upload cancelled') {
super(message)
this.name = 'UploadCancelledError'
}
}
class PartialUploadError extends Error {
constructor(message = 'Not all recordings succeeded to upload.') {
super(message)
this.name = 'PartialUploadError'
}
}
class RecordFailureHintError extends Error {
readonly cause: Error
constructor(cause: Error) {
super(cause.message)
this.name = cause.name
this.cause = cause
}
}
function isUploadCancelledError(err: unknown): boolean {
return (
err instanceof UploadCancelledError ||
(err instanceof Error &&
(err.name === 'AbortError' || err.name === 'UploadCancelledError'))
)
}
function isPartialUploadError(err: unknown): boolean {
return err instanceof PartialUploadError
}
function isRecordFailureHintError(err: unknown): err is RecordFailureHintError {
return err instanceof RecordFailureHintError
}
function isUploadAssetError(err: unknown): boolean {
return err instanceof UploadAssetError
}
function supportsInPlaceUploadUpdates(verbose: boolean): boolean {
return !verbose && process.stdout.isTTY === true && !process.env.CI
}
function formatUploadProgressLine(
videoName: string,
status?: UploadProgressStatus
): string {
switch (status) {
case undefined:
return `${pc.cyan('...')} Uploading "${videoName}"`
case 'success':
return `${pc.green('✔')} Uploaded "${videoName}"`
case 'failure':
return `${pc.red('✖')} Failed to upload "${videoName}"`
case 'cancelled':
return `${pc.yellow('!')} Cancelled "${videoName}"`
default: {
const exhaustiveCheck: never = status
return exhaustiveCheck
}
}
}
function createUploadProgressReporter(
videoNames: readonly string[],
verbose: boolean
): {
complete: (index: number, status: UploadProgressStatus) => void
info: (message: string) => void
} {
const useInPlaceUpdates = supportsInPlaceUploadUpdates(verbose)
if (!useInPlaceUpdates) {
return {
complete(index, status) {
logger.info(
formatUploadProgressLine(videoNames[index] ?? 'unknown', status)
)
},
info(message) {
logger.info(message)
},
}
}
const statuses = new Array<UploadProgressStatus | undefined>(
videoNames.length
)
let hasRendered = false
const render = () => {
const renderedLines = videoNames.map((videoName, index) =>
formatUploadProgressLine(videoName, statuses[index])
)
process.stdout.write(
`${hasRendered && videoNames.length > 0 ? `\u001B[${videoNames.length}A` : ''}${renderedLines.map((line) => `\r\u001B[2K${line}`).join('\n')}\n`
)
hasRendered = true
}
const clear = () => {
if (!hasRendered || videoNames.length === 0) return
process.stdout.write(
`\u001B[${videoNames.length}A${Array.from({ length: videoNames.length }, () => '\r\u001B[2K').join('\n')}\n`
)
hasRendered = false
}
render()
return {
complete(index, status) {
statuses[index] = status
render()
},
info(message) {
clear()
logger.info(message)
render()
},
}
}
function shouldKeepRecordedArtifacts(): boolean {
return process.env.DEBUG === 'true'
}
function cleanupUploadedRecordingDir(screenciDir: string, entry: string): void {
if (shouldKeepRecordedArtifacts()) return
try {
rmSync(resolve(screenciDir, entry), { recursive: true, force: true })
} catch (error) {
logger.warn(
`Uploaded recording cleanup failed for "${entry}": ${error instanceof Error ? error.message : String(error)}`
)
}
}
async function loadUploadCandidate(
screenciDir: string,
entry: string,
verbose: boolean
): Promise<UploadCandidate | null> {
const dataJsonPath = resolve(screenciDir, entry, 'data.json')
if (!existsSync(dataJsonPath)) {
if (verbose) logger.info(`Skipping "${entry}": no data.json found`)
return null
}
let data: RecordingData
try {
const raw = await readFile(dataJsonPath, 'utf-8')
data = JSON.parse(raw) as RecordingData
} catch {
logger.warn(`Failed to read ${dataJsonPath}, skipping`)
return null
}
const videoName = data.metadata?.videoName ?? entry
const preparedUploadAssets = await collectUploadAssets(
data,
resolve(screenciDir, '..')
)
return {
entry,
videoName,
data: annotateRecordingDataWithAssetHashes(data, preparedUploadAssets),
preparedUploadAssets,
}
}
async function uploadRecordingCandidate(
candidate: UploadCandidate,
screenciDir: string,
projectName: string,
apiUrl: string,
secret: string,
elevenLabsApiKey: string | undefined,
verbose: boolean,
uploadAbort: ReturnType<typeof createUploadAbortController>,
progressReporter: {
complete: (index: number, status: UploadProgressStatus) => void
info: (message: string) => void
},
progressIndex: number,
recordId: string
): Promise<UploadJobResult> {
const { entry, videoName, data, preparedUploadAssets } = candidate
let projectId: string | null = null
try {
uploadAbort.throwIfAborted()
const recordingPath = resolve(screenciDir, entry, 'recording.mp4')
if (!existsSync(recordingPath)) {
progressReporter.complete(progressIndex, 'failure')
return {
projectId: null,
hadFailure: true,
videoName,
failureMessage: `Missing recording.mp4 for "${videoName}"`,
recordId,
}
}
const recordingHash = await hashFile(recordingPath)
const startResponse = await fetch(`${apiUrl}/cli/upload/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-ScreenCI-Secret': secret,
...(elevenLabsApiKey
? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
: {}),
},
body: JSON.stringify({
projectName,
videoName,
data,
recordingHash,
recordId,
expectedAssets: preparedUploadAssets.map((asset) => ({
fileHash: asset.fileHash,
size: asset.size,
path: asset.path,
...(typeof asset.contentType === 'string'
? { contentType: asset.contentType }
: {}),
...(typeof asset.name === 'string' ? { name: asset.name } : {}),
})),
}),
signal: uploadAbort.signal,
})
if (!startResponse.ok) {
const text = await startResponse.text()
progressReporter.complete(progressIndex, 'failure')
return {
projectId: null,
hadFailure: true,
videoName,
failureMessage: formatUploadStartFailureMessage(
videoName,
startResponse.status,
text,
secret
),
recordId,
}
}
const startBody = (await startResponse.json()) as {
recordingId: string
projectId: string
}
const { recordingId } = startBody
projectId = startBody.projectId
if (verbose) {
logger.info(`recordingId=${recordingId} projectId=${projectId}`)
logger.info(
`assets=${preparedUploadAssets.length} recordingHash=${recordingHash ?? 'none'}`
)
}
await uploadAssets(
preparedUploadAssets,
apiUrl,
secret,
recordingId,
uploadAbort.signal,
uploadAbort.throwIfAborted,
progressReporter
)
uploadAbort.throwIfAborted()
const fileStat = await stat(recordingPath)
if (verbose) {
logger.info(
`Uploading recording.mp4 size=${(fileStat.size / 1024 / 1024).toFixed(1)}MB`
)
}
const stream = createReadStream(recordingPath)
const abortStream = () => {
stream.destroy(
new UploadCancelledError(`Upload cancelled for "${videoName}"`)
)
}
uploadAbort.signal.addEventListener('abort', abortStream, {
once: true,
})
try {
const recordingResponse = await fetch(
`${apiUrl}/cli/upload/${recordingId}/recording`,
{
method: 'PUT',
headers: {
'Content-Type': 'video/mp4',
'Content-Length': String(fileStat.size),
'X-ScreenCI-Secret': secret,
...(elevenLabsApiKey
? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
: {}),
},
body: stream as unknown as BodyInit,
signal: uploadAbort.signal,
// @ts-expect-error Node.js fetch supports duplex for streaming
duplex: 'half',
}
)
if (!recordingResponse.ok) {
const text = await recordingResponse.text()
progressReporter.complete(progressIndex, 'failure')
return {
projectId,
hadFailure: true,
videoName,
failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
recordId,
}
}
} finally {
uploadAbort.signal.removeEventListener('abort', abortStream)
}
progressReporter.complete(progressIndex, 'success')
cleanupUploadedRecordingDir(screenciDir, entry)
return { projectId, hadFailure: false, videoName, recordId }
} catch (err) {
if (isUploadCancelledError(err)) {
progressReporter.complete(progressIndex, 'cancelled')
throw err
}
if (isUploadAssetError(err)) {
progressReporter.complete(progressIndex, 'failure')
return {
projectId,
hadFailure: true,
videoName,
failureMessage: err instanceof Error ? err.message : String(err),
recordId,
}
}
progressReporter.complete(progressIndex, 'failure')
return {
projectId,
hadFailure: true,
videoName,
failureMessage: `Network error uploading "${videoName}": ${err instanceof Error ? err.message : String(err)}`,
recordId,
}
}
}
export function attachUploadAbortStdinListener(
input: Pick<NodeJS.ReadStream, 'on' | 'off' | 'pause'>,
onAbort: (signal: NodeJS.Signals) => void
): () => void {
const handleStdinData = (chunk: Buffer | string) => {
const bytes =
typeof chunk === 'string'
? Buffer.from(chunk, 'utf8')
: Buffer.from(chunk)
if (bytes.includes(0x03)) {
onAbort('SIGINT')
}
}
input.on('data', handleStdinData)
return () => {
input.off('data', handleStdinData)
input.pause()
}
}
function createUploadAbortController(activityLabel: string): {
signal: AbortSignal
throwIfAborted: () => void
cleanup: () => void
} {
const controller = new AbortController()
let cleanedUp = false
const cleanupStdinListener = attachUploadAbortStdinListener(
process.stdin,
(signal) => abortUpload(signal)
)
const cleanup = () => {
if (cleanedUp) return
cleanedUp = true
process.off('SIGINT', handleSigint)
process.off('SIGTERM', handleSigterm)
cleanupStdinListener()
}
function abortUpload(signal: NodeJS.Signals) {
if (controller.signal.aborted) return
logger.info(`Received ${signal}, stopping ${activityLabel}...`)
cleanup()
controller.abort(new UploadCancelledError(`${activityLabel} cancelled`))
process.kill(process.pid, signal)
}
const handleSigint = () => abortUpload('SIGINT')
const handleSigterm = () => abortUpload('SIGTERM')
process.on('SIGINT', handleSigint)
process.on('SIGTERM', handleSigterm)
return {
signal: controller.signal,
throwIfAborted: () => {
if (controller.signal.aborted) {
throw controller.signal.reason instanceof Error
? controller.signal.reason
: new UploadCancelledError(`${activityLabel} cancelled`)
}
},
cleanup,
}
}
function resolveSpawnSpec(
cmd: string,
args: string[]
): {
command: string
args: string[]
shell?: boolean
windowsVerbatimArguments?: boolean
} {
if (process.platform !== 'win32') {
return { command: cmd, args }
}
const windowsCmdShims = new Set(['npm', 'npx', 'playwright', 'pnpm'])
if (!windowsCmdShims.has(cmd)) {
return { command: cmd, args }
}
return {
command: process.env.comspec ?? 'cmd.exe',
args: ['/d', '/s', '/c', `"${buildWindowsBatchCommandLine(cmd, args)}"`],
windowsVerbatimArguments: true,
}
}
function quoteWindowsBatchArg(arg: string): string {
if (arg.length === 0) {
return '""'
}
return `"${arg
.replace(/(\\*)"/g, '$1$1\\"')
.replace(/(\\+)$/g, '$1$1')
.replace(/%/g, '%%')}"`
}
function buildWindowsBatchCommandLine(cmd: string, args: string[]): string {
return [resolveWindowsCmdShim(cmd), ...args]
.map(quoteWindowsBatchArg)
.join(' ')
}
function resolveWindowsCmdShim(cmd: string): string {
const shimName = `${cmd}.cmd`
const pathEntries = process.env.PATH?.split(delimiter) ?? []
for (const entry of pathEntries) {
if (!entry) continue
const shimPath = resolve(entry, shimName)
if (existsSync(shimPath)) {
return shimPath
}
}
const bundledShimCommands = new Set(['npm', 'npx'])
if (bundledShimCommands.has(cmd)) {
const bundledShimPath = resolve(dirname(process.execPath), shimName)
if (existsSync(bundledShimPath)) {
return bundledShimPath
}
}
return shimName
}
function resolvePlaywrightSpawnSpec(
args: string[],
searchFrom: string
): {
command: string
args: string[]
shell?: boolean
windowsVerbatimArguments?: boolean
} {
const cliEntrypoint = require.resolve('@playwright/test/cli', {
paths: [searchFrom],
})
return {
command: process.execPath,
args: [cliEntrypoint, ...args],
}
}
function forwardChildSignals(
child: ChildProcess,
activityLabel: string,
options: { killTree?: boolean; exitParentOnForward?: boolean } = {}
): { cleanup: () => void; getForwardedSignal: () => NodeJS.Signals | null } {
let forwardedSignal: NodeJS.Signals | null = null
let forceKillTimer: NodeJS.Timeout | null = null
const killTree = options.killTree ?? false
const exitParentOnForward = options.exitParentOnForward ?? false
const killChild = (signal: NodeJS.Signals): void => {
if (child.pid === undefined) return
if (killTree && process.platform !== 'win32') {
try {
process.kill(-child.pid, signal)
return
} catch {
// Fall back to direct child kill below.
}
}
if (!child.killed) {
child.kill(signal)
}
}
const forwardSignal = (signal: NodeJS.Signals) => {
if (forwardedSignal !== null) return
forwardedSignal = signal
if (process.env.SCREENCI_SIGNAL_LOGGING !== 'silent') {
logger.info(`Received ${signal}, stopping ${activityLabel}...`)
}
killChild(signal)
if (exitParentOnForward) {
cleanup()
process.exit(signal === 'SIGINT' ? 130 : 143)
}
forceKillTimer = setTimeout(() => {
if (child.exitCode === null) {
if (process.env.SCREENCI_SIGNAL_LOGGING !== 'silent') {
logger.info(`Forcing ${activityLabel} to stop after timeout...`)
}
killChild('SIGKILL')
process.exit(signal === 'SIGINT' ? 130 : 143)
}
}, 3000)
forceKillTimer.unref()
}
const handleSigint = () => forwardSignal('SIGINT')
const handleSigterm = () => forwardSignal('SIGTERM')
const cleanupStdinListener = attachUploadAbortStdinListener(
process.stdin,
(signal) => {
if (process.env.SCREENCI_SIGNAL_LOGGING !== 'silent') {
logger.info(`Received ${signal}, stopping ${activityLabel}...`)
}
forwardSignal(signal)
}
)
const cleanup = () => {
if (forceKillTimer !== null) {
clearTimeout(forceKillTimer)
}
process.off('SIGINT', handleSigint)
process.off('SIGTERM', handleSigterm)
cleanupStdinListener()
}
process.on('SIGINT', handleSigint)
process.on('SIGTERM', handleSigterm)
return {
cleanup,
getForwardedSignal: () => forwardedSignal,
}
}
function clearRecordingDirectories(dir: string): void {
mkdirSync(dir, { recursive: true })
for (const entry of readdirSync(dir)) {
if (entry === SCREENCI_LINK_SESSION_FILE) continue
rmSync(resolve(dir, entry), { recursive: true, force: true })
}
}
function findScreenCIConfig(customPath?: string): string | null {
if (customPath) {
const resolvedPath = resolve(process.cwd(), customPath)
if (existsSync(resolvedPath)) {
return resolvedPath
}
return null
}
const cwd = process.cwd()
const configPath = resolve(cwd, 'screenci.config.ts')
if (existsSync(configPath)) {
return configPath
}
return null
}
async function hashFile(filePath: string): Promise<string> {
return new Promise((resolveHash, reject) => {
const hash = createHash('sha256')
const stream = createReadStream(filePath)
stream.on('data', (chunk) => hash.update(chunk))
stream.on('error', reject)
stream.on('end', () => resolveHash(hash.digest('hex')))
})
}
async function prepareCustomVoiceAssets(
data: RecordingData,
configDir: string
): Promise<PreparedUploadAsset[]> {
const sourceFilePath = data.metadata?.sourceFilePath
const customVoiceRefsByPath = new Map<string, CustomVoiceRefLike[]>()
for (const event of data.events) {
if (event.type === 'cueStart' && event.translations) {
for (const translation of Object.values(event.translations)) {
if (
typeof translation.voice === 'object' &&
translation.voice !== null &&
'assetPath' in translation.voice &&
typeof translation.voice.assetPath === 'string'
) {
const voiceRef = translation.voice as CustomVoiceRefLike
const refs = customVoiceRefsByPath.get(voiceRef.assetPath) ?? []
refs.push(voiceRef)
customVoiceRefsByPath.set(voiceRef.assetPath, refs)
}
}
}
if (event.type === 'videoCueStart' && event.translations) {
for (const translation of Object.values(event.translations)) {
if (
'text' in translation &&
typeof translation.voice === 'object' &&
translation.voice !== null &&
'assetPath' in translation.voice &&
typeof translation.voice.assetPath === 'string'
) {
const voiceRef = translation.voice as CustomVoiceRefLike
const refs = customVoiceRefsByPath.get(voiceRef.assetPath) ?? []
refs.push(voiceRef)
customVoiceRefsByPath.set(voiceRef.assetPath, refs)
}
}
}
}
const preparedAssets: PreparedUploadAsset[] = []
for (const [voicePath, refs] of customVoiceRefsByPath) {
const resolvedFile = await readRecordingFile(
voicePath,
configDir,
sourceFilePath
)
if (resolvedFile === null) {
const existingHash = refs.find(
(ref) => typeof ref.assetHash === 'string'
)?.assetHash
if (!existingHash) {
throw new Error(
`Custom voice file not found and no cached assetHash available: ${voicePath}`
)
}
logger.warn(
`Custom voice file not found locally, assuming previously uploaded recording asset is valid: ${voicePath}`
)
for (const ref of refs) {
ref.assetHash = existingHash
}
preparedAssets.push({
fileHash: existingHash,
path: voicePath,
size: 0,
contentType: contentTypeForPath(voicePath),
})
continue
}
const { buffer: fileBuffer, resolvedPath } = resolvedFile
const assetHash = createHash('sha256').update(fileBuffer).digest('hex')
const contentType = contentTypeForPath(resolvedPath)
for (const ref of refs) {
ref.assetHash = assetHash
}
preparedAssets.push({
fileHash: assetHash,
path: voicePath,
size: fileBuffer.byteLength,
fileBuffer,
contentType,
})
}
return preparedAssets
}
async function collectUploadAssets(
data: RecordingData,
configDir: string
): Promise<PreparedUploadAsset[]> {
const sourceFilePath = data.metadata?.sourceFilePath
const assets = new Map<string, PreparedUploadAsset>()
for (const event of data.events) {
if (event.type === 'assetStart') {
if (assets.has(`name:${event.name}`)) continue
const resolvedFile = await readRecordingFile(
event.path,
configDir,
sourceFilePath
)
if (resolvedFile === null) {
logger.warn(`Asset file not found, skipping upload: ${event.path}`)
continue
}
const { buffer: fileBuffer, resolvedPath } = resolvedFile
assets.set(`name:${event.name}`, {
fileHash: createHash('sha256').update(fileBuffer).digest('hex'),
path: event.path,
name: event.name,
size: fileBuffer.byteLength,
fileBuffer,
contentType: contentTypeForPath(resolvedPath),
})
continue
}
if (event.type === 'videoCueStart') {
// Single-language: hash already computed during recording, use assetPath to read file
if (
typeof event.assetHash === 'string' &&
!assets.has(`hash:${event.assetHash}`)
) {
const resolvedFile =
typeof event.assetPath === 'string'
? await readRecordingFile(
event.assetPath,
configDir,
sourceFilePath
)
: null
assets.set(`hash:${event.assetHash}`, {
fileHash: event.assetHash,
path: event.assetPath ?? event.assetHash,
size: resolvedFile?.buffer.byteLength ?? 0,
...(resolvedFile !== null && {
fileBuffer: resolvedFile.buffer,
contentType: contentTypeForPath(resolvedFile.resolvedPath),
}),
})
}
// Multi-language: each translation carries its own hash
if (event.translations) {
for (const translation of Object.values(event.translations)) {
if (
typeof translation === 'object' &&
translation !== null &&
'assetHash' in translation &&
typeof translation.assetHash === 'string' &&
!assets.has(`hash:${translation.assetHash}`)
) {
const resolvedFile =
'assetPath' in translation &&
typeof translation.assetPath === 'string'
? await readRecordingFile(
translation.assetPath,
configDir,
sourceFilePath
)
: null
assets.set(`hash:${translation.assetHash}`, {
fileHash: translation.assetHash,
path:
(translation as { assetPath?: string }).assetPath ??
translation.assetHash,
size: resolvedFile?.buffer.byteLength ?? 0,
...(resolvedFile !== null && {
fileBuffer: resolvedFile.buffer,
contentType: contentTypeForPath(resolvedFile.resolvedPath),
}),
})
}
}
}
}
}
for (const asset of await prepareCustomVoiceAssets(data, configDir)) {
assets.set(`path:${asset.path}`, asset)
}
return [...assets.values()]
}
export function stripVoicePath(
voice: VoiceKey | RecordingCustomVoiceRef
): VoiceKey | RecordingCustomVoiceRef {
if (typeof voice !== 'string') {
return { assetHash: voice.assetHash }
}
return voice
}
export function annotateRecordingDataWithAssetHashes(
data: RecordingData,
assets: PreparedUploadAsset[]
): RecordingData {
const byName = new Map<string, string>()
for (const asset of assets) {
if (typeof asset.name === 'string') byName.set(asset.name, asset.fileHash)
}
return {
...data,
events: data.events.map((event) => {
if (event.type === 'assetStart') {
const fileHash = byName.get(event.name)
return fileHash ? { ...event, fileHash } : event
}
if (event.type === 'cueStart' && event.translations) {
const translations = Object.fromEntries(
Object.entries(event.translations).map(([language, translation]) => {
if (translation.voice === undefined) {
return [language, translation]
}
return [
language,
{
...translation,
voice: stripVoicePath(translation.voice),
} as typeof translation,
]
})
)
return { ...event, translations }
}
if (event.type !== 'videoCueStart') return event
// Strip assetPath from translations — hash was already computed during recording
if (event.translations) {
const translations = Object.fromEntries(
Object.entries(event.translations).map(([language, translation]) => {
if ('assetHash' in translation) {
const { assetPath: _removed, ...rest } =
translation as VideoCueTranslationFile
return [language, rest]
}
if ('voice' in translation) {
return [
language,
{
...translation,
...(translation.voice !== undefined
? { voice: stripVoicePath(translation.voice) }
: {}),
},
]
}
return [language, translation]
})
)
return { ...event, translations }
}
// Single-language: strip assetPath, keep assetHash
if (typeof event.assetHash === 'string') {
const { assetPath: _removed, ...rest } = event
return rest
}
return event
}),
}
}
function hint401(status: number, secret: string): string {
if (status !== 401 || !secret) return ''
const frontendUrl = getDevFrontendUrl()
return `\nThe secret may have been deleted or belongs to a different organisation. Check your secrets at ${frontendUrl}/secrets`
}
export function formatUploadStartFailureMessage(
videoName: string,
status: number,
responseText: string,
secret: string
): string {
if (responseText.trim().length > 0) {
try {
const parsed = JSON.parse(responseText) as { error?: unknown }
if (typeof parsed.error === 'string' && parsed.error.trim().length > 0) {
return `${parsed.error}${hint401(status, secret)}`
}
} catch {
// fall back to raw response text
}
return responseText
}
return `Failed to start upload for "${videoName}": ${status}${hint401(status, secret)}`
}
const EXPRESSIVE_TIER_ERROR_PREFIX =
'Expressive narration and style prompts require the Business tier.'
export function formatFailedVideoMessage(
videoName: string,
message: string
): string {
if (message.startsWith(EXPRESSIVE_TIER_ERROR_PREFIX)) {
return [
`${videoName}: ${message}`,
"If you want to keep using the current tier, remove `voice.style` or `modelType: 'expressive'` from `createNarration()`.",
].join('\n')
}
return `${videoName}: ${message}`
}
export function printUploadStartFailureMessage(
videoName: string,
status: number,
responseText: string,
secret: string
): void {
const message = formatUploadStartFailureMessage(
videoName,
status,
responseText,
secret
)
if (responseText.trim().length > 0) {
process.stderr.write(`${message}\n`)
return
}
logger.warn(message)
}
async function uploadAssets(
assets: PreparedUploadAsset[],
apiUrl: string,
secret: string,
recordingId: string,
signal: AbortSignal,
throwIfAborted: () => void,
progressReporter?: { info: (message: string) => void }
): Promise<void> {
const logInfo = (message: string) => {
if (progressReporter) {
progressReporter.info(message)
} else {
logger.info(message)
}
}
for (const asset of assets) {
throwIfAborted()
try {
const checkRes = await fetch(
`${apiUrl}/cli/upload/${recordingId}/asset/check`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-ScreenCI-Secret': secret,
},
body: JSON.stringify({
fileHash: asset.fileHash,
contentType: asset.contentType,
size: asset.size,
path: asset.path,
...(typeof asset.name === 'string' ? { name: asset.name } : {}),
}),
signal,
}
)
if (!checkRes.ok) {
const text = await checkRes.text()
throw new UploadAssetError(
`Failed to check asset ${asset.path}: ${checkRes.status} ${text}${hint401(checkRes.status, secret)}`
)
}
const checkBody = (await checkRes.json()) as { exists: boolean }
if (checkBody.exists) {
logInfo(`${pc.green('✔')} Asset already exists: ${asset.path}`)
continue
}
if (!asset.fileBuffer || !asset.contentType) {
throw new UploadAssetError(
`Asset bytes not available for upload and backend does not have it yet: ${asset.path}`
)
}
throwIfAborted()
const res = await fetch(`${apiUrl}/cli/upload/${recordingId}/asset`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-ScreenCI-Secret': secret,
},
body: JSON.stringify({
fileHash: asset.fileHash,
fileBase64: asset.fileBuffer.toString('base64'),
contentType: asset.contentType,
size: asset.size,
path: asset.path,
...(typeof asset.name === 'string' ? { name: asset.name } : {}),
}),
signal,
})
if (!res.ok) {
const text = await res.text()
if (res.status === 409 && text.includes('already exists')) {
logInfo(`${pc.green('✔')} Asset already exists: ${asset.path}`)
} else {
throw new UploadAssetError(
`Failed to upload asset ${asset.path}: ${res.status} ${text}${hint401(res.status, secret)}`
)
}
} else {
logInfo(`Asset uploaded: ${asset.path}`)
}
} catch (err) {
if (isUploadCancelledError(err)) {
throw err
}
if (isUploadAssetError(err)) {
throw err
}
throw new UploadAssetError(
`Network error uploading asset ${asset.path}: ${err instanceof Error ? err.message : String(err)}`
)
}
}
}
export async function uploadRecordings(
screenciDir: string,
projectName: string,
apiUrl: string,
secret: string,
specificEntry?: string,
verbose = false
): Promise<{
projectId: string | null
recordId: string | null
hadFailures: boolean
failedVideoNames: string[]
failedVideoMessages: Array<{ videoName: string; message: string }>
}> {
const uploadAbort = createUploadAbortController('upload')
const recordId = randomUUID()
let entries: string[]
try {
entries = await readdir(screenciDir)
} catch {
logger.warn('No .screenci directory found, skipping upload')
return {
projectId: null,
recordId: null,
hadFailures: false,
failedVideoNames: [],
failedVideoMessages: [],
}
}
if (specificEntry !== undefined) {
entries = entries.filter((e) => e === specificEntry)
}
let firstProjectId: string | null = null
const elevenLabsApiKey = process.env.ELEVENLABS_API_KEY?.trim() || undefined
try {
const candidates = (
await Promise.all(
entries.map(async (entry) => {
uploadAbort.throwIfAborted()
return await loadUploadCandidate(screenciDir, entry, verbose)
})
)
).filter((candidate): candidate is UploadCandidate => candidate !== null)
if (candidates.length === 0) {
return {
projectId: null,
recordId: null,
hadFailures: false,
failedVideoNames: [],
failedVideoMessages: [],
}
}
const progressReporter = createUploadProgressReporter(
candidates.map((candidate) => candidate.videoName),
verbose
)
const results = await Promise.all(
candidates.map(
async (candidate, index) =>
await uploadRecordingCandidate(
candidate,
screenciDir,
projectName,
apiUrl,
secret,
elevenLabsApiKey,
verbose,
uploadAbort,
progressReporter,
index,
recordId
)
)
)
firstProjectId =
results.find((result) => result.projectId !== null)?.projectId ?? null
const hadFailures = results.some((result) => result.hadFailure)
const failedVideoNames = results
.filter((result) => result.hadFailure)
.map((result) => result.videoName)
const failedVideoMessages = results.flatMap((result) =>
result.hadFailure && typeof result.failureMessage === 'string'
? [{ videoName: result.videoName, message: result.failureMessage }]
: []
)
return {
projectId: firstProjectId,
recordId,
hadFailures,
failedVideoNames,
failedVideoMessages,
}
} finally {
uploadAbort.cleanup()
}
}
async function countCompletedRecordings(screenciDir: string): Promise<number> {
let entries: string[]
try {
entries = await readdir(screenciDir)
} catch {
return 0
}
return entries.filter((entry) =>
existsSync(resolve(screenciDir, entry, 'data.json'))
).length
}
export function getDevBackendUrl(): string {
switch (getScreenCIEnvironment()) {
case 'local': {
const devBackendPort = process.env.DEV_BACKEND_PORT
return devBackendPort
? `http://localhost:${devBackendPort}`
: 'http://localhost:8787'
}
case 'dev':
return SCREENCI_DEVELOPMENT_BACKEND_URL
case 'prod':
return SCREENCI_PRODUCTION_BACKEND_URL
}
}
export function getDevFrontendUrl(): string {
switch (getScreenCIEnvironment()) {
case 'local': {
const devFrontendPort = process.env.DEV_FRONTEND_PORT
return devFrontendPort
? `http://localhost:${devFrontendPort}`
: 'http://localhost:5173'
}
case 'dev':
return SCREENCI_DEVELOPMENT_FRONTEND_URL
case 'prod':
return SCREENCI_PRODUCTION_FRONTEND_URL
}
}
export function getCliLinkSessionApiUrl(): string {
return getDevFrontendUrl()
}
function getScreenCISecretsUrl(): string {
return `${getDevFrontendUrl()}/secrets`
}
async function writeGitHubProjectOutput(projectUrl: string): Promise<void> {
const githubOutput = process.env.GITHUB_OUTPUT
if (!githubOutput) return
await appendFile(githubOutput, `screenci_project_url=${projectUrl}\n`)
}
async function loadScreenCIConfigAndEnv(configPath?: string): Promise<{
resolvedConfigPath: string
screenciConfig: ScreenCIConfig
}> {
const resolvedConfigPath = findScreenCIConfig(configPath)
if (!resolvedConfigPath) {
const errorMsg = configPath
? `Error: Config file not found: ${configPath}`
: 'Error: screenci.config.ts not found in current directory'
logger.error(errorMsg)
process.exit(1)
}
let screenciConfig: ScreenCIConfig
try {
screenciConfig =
await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath)
} catch (err) {
logger.error('Failed to load config:', err)
process.exit(1)
}
if (screenciConfig.envFile) {
loadEnvFile(
resolve(dirname(resolvedConfigPath), screenciConfig.envFile),
true
)
} else {
loadEnvFile(resolve(dirname(resolvedConfigPath), '.env'), false)
}
return { resolvedConfigPath, screenciConfig }
}
function loadEnvFile(envFilePath: string, warnOnFailure: boolean): void {
try {
const loadEnvFileCompat = (
process as NodeJS.Process & {
loadEnvFile?: (path: string | URL) => void
}
).loadEnvFile
if (typeof loadEnvFileCompat === 'function') {
loadEnvFileCompat(envFilePath)
return
}
loadEnvFileFallback(envFilePath)
} catch (err) {
if (warnOnFailure && !isMissingFileError(err)) {
logger.warn(`Failed to load env file ${envFilePath}:`, err)
}
}
}
function loadEnvFileFallback(envFilePath: string): void {
const envSource = readFileSync(envFilePath, 'utf8')
for (const [key, value] of parseEnvFile(envSource)) {
if (process.env[key] === undefined) {
process.env[key] = value
}
}
}
function parseEnvFile(envSource: string): Map<string, string> {
const parsed = new Map<string, string>()
const lines = envSource.replace(/^\uFEFF/, '').split(/\r?\n/)
for (const rawLine of lines) {
const line = rawLine.trim()
if (line === '' || line.startsWith('#')) continue
const normalizedLine = line.startsWith('export ')
? line.slice('export '.length).trimStart()
: line
const separatorIndex = normalizedLine.indexOf('=')
if (separatorIndex === -1) continue
const key = normalizedLine.slice(0, separatorIndex).trim()
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue
const rawValue = normalizedLine.slice(separatorIndex + 1).trim()
parsed.set(key, parseEnvValue(rawValue))
}
return parsed
}
function parseEnvValue(rawValue: string): string {
if (rawValue === '') return ''
if (
(rawValue.startsWith('"') && rawValue.endsWith('"')) ||
(rawValue.startsWith("'") && rawValue.endsWith("'"))
) {
const quote = rawValue[0]
const quotedValue = rawValue.slice(1, -1)
if (quote === '"') {
return quotedValue
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t')
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\')
}
return quotedValue
}
const inlineCommentIndex = rawValue.search(/\s#/)
if (inlineCommentIndex >= 0) {
return rawValue.slice(0, inlineCommentIndex).trimEnd()
}
return rawValue
}
function isMissingFileError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'code' in err &&
err.code === 'ENOENT'
)
}
async function loadEnvFileFromConfigSource(
resolvedConfigPath: string,
warnOnFailure: boolean
): Promise<void> {
try {
const screenciConfig = await tryReadConfigFromSource(resolvedConfigPath)
loadEnvFile(
screenciConfig.envFile
? resolve(dirname(resolvedConfigPath), screenciConfig.envFile)
: resolve(dirname(resolvedConfigPath), '.env'),
warnOnFailure
)
} catch {
// Config import may require Playwright context or dynamic values. Continue with
// the existing process env; Playwright will still load the config normally.
}
}
async function resolveConfiguredEnvFilePath(
resolvedConfigPath: string
): Promise<string | undefined> {
try {
const screenciConfig = await tryReadConfigFromSource(resolvedConfigPath)
if (!screenciConfig.envFile) return undefined
return resolve(dirname(resolvedConfigPath), screenciConfig.envFile)
} catch {
return undefined
}
}
async function resolveProjectEnvFilePath(
resolvedConfigPath: string
): Promise<string> {
return (
(await resolveConfiguredEnvFilePath(resolvedConfigPath)) ??
resolve(dirname(resolvedConfigPath), '.env')
)
}
export function extractConfigStringLiteral(
configSource: string,
property: 'projectName' | 'envFile'
): string | undefined {
const singleQuoteMatch = configSource.match(
new RegExp(property + "\\s*:\\s*'([^'\\n]+)'")
)
if (singleQuoteMatch) return singleQuoteMatch[1]
const doubleQuoteMatch = configSource.match(
new RegExp(property + '\\s*:\\s*"([^"\\n]+)"')
)
if (doubleQuoteMatch) return doubleQuoteMatch[1]
const templateLiteralMatch = configSource.match(
new RegExp(property + '\\s*:\\s*`([^`\\n]+)`')
)
return templateLiteralMatch?.[1]
}
export function extractRecordUploadPolicyLiteral(
configSource: string
): RecordUploadPolicy | undefined {
const singleQuoteMatch = configSource.match(
/record\s*:\s*\{[\s\S]*?upload\s*:\s*'(passed-only|all-or-nothing)'/
)
if (singleQuoteMatch) {
return singleQuoteMatch[1] as RecordUploadPolicy
}
const doubleQuoteMatch = configSource.match(
/record\s*:\s*\{[\s\S]*?upload\s*:\s*"(passed-only|all-or-nothing)"/
)
if (doubleQuoteMatch) {
return doubleQuoteMatch[1] as RecordUploadPolicy
}
const templateLiteralMatch = configSource.match(
/record\s*:\s*\{[\s\S]*?upload\s*:\s*`(passed-only|all-or-nothing)`/
)
return templateLiteralMatch?.[1] as RecordUploadPolicy | undefined
}
export function extractMockRecordLiteral(
configSource: string
): boolean | undefined {
const match = configSource.match(
/test\s*:\s*\{[\s\S]*?mockRecord\s*:\s*(true|false)/
)
if (!match) return undefined
return match[1] === 'true'
}
function resolveRecordUploadPolicy(config: ScreenCIConfig): RecordUploadPolicy {
return config.record?.upload ?? DEFAULT_RECORD_UPLOAD_POLICY
}
async function tryReadConfigFromSource(resolvedConfigPath: string): Promise<
Pick<ScreenCIConfig, 'projectName'> & {
envFile?: string
record?: { upload?: RecordUploadPolicy }
test?: { mockRecord?: boolean }
}
> {
const configSource = await readFile(resolvedConfigPath, 'utf-8')
const projectName = extractConfigStringLiteral(configSource, 'projectName')
if (!projectName) {
throw new Error(
'Could not determine projectName from screenci.config.ts without importing it.'
)
}
const envFile = extractConfigStringLiteral(configSource, 'envFile')
const recordUpload = extractRecordUploadPolicyLiteral(configSource)
const mockRecord = extractMockRecordLiteral(configSource)
return {
projectName,
...(envFile !== undefined ? { envFile } : {}),
...(recordUpload !== undefined ? { record: { upload: recordUpload } } : {}),
...(mockRecord !== undefined ? { test: { mockRecord } } : {}),
}
}
export function getConfigModuleSpecifier(resolvedConfigPath: string): string {
if (
process.platform === 'win32' &&
/^[A-Za-z]:[\\/]/.test(resolvedConfigPath)
) {
return encodeURI(`file:///${resolvedConfigPath.replace(/\\/g, '/')}`)
}
return pathToFileURL(resolvedConfigPath).href
}
async function loadRecordConfigWithoutPlaywrightCollision(
resolvedConfigPath: string
): Promise<ScreenCIConfig> {
try {
const configModule = await import(
getConfigModuleSpecifier(resolvedConfigPath)
)
return configModule.default as ScreenCIConfig
} catch (err) {
const hasPlaywrightCollision =
err instanceof Error &&
err.message.includes('Requiring @playwright/test second time')
if (hasPlaywrightCollision) {
logger.warn(
'Playwright was loaded from multiple module paths. Falling back to static config parsing for upload metadata.'
)
}
try {
return (await tryReadConfigFromSource(
resolvedConfigPath
)) as ScreenCIConfig
} catch {
// Preserve the original import error when static parsing cannot recover.
}
throw err
}
}
async function requireScreenCISecret(configPath?: string): Promise<{
resolvedConfigPath: string
screenciConfig: ScreenCIConfig
secret: string
apiUrl: string
}> {
const { resolvedConfigPath, screenciConfig } =
await loadScreenCIConfigAndEnv(configPath)
const secret =
process.env.SCREENCI_SECRET ??
(await ensureScreenciSecret(resolvedConfigPath))
if (!secret) {
const envFilePath = await resolveProjectEnvFilePath(resolvedConfigPath)
logger.error(
`No SCREENCI_SECRET configured. Rerun ${pc.cyan(getSuggestedScreenciCommand('record'))} or add SCREENCI_SECRET manually to ${envFilePath} by following the guide at ${pc.cyan(SCREENCI_RECORD_DOCS_URL)}.`
)
process.exit(1)
}
return {
resolvedConfigPath,
screenciConfig,
secret,
apiUrl: getDevBackendUrl(),
}
}
async function fetchProjectInfo(
configPath?: string
): Promise<ProjectInfoResponse> {
const { screenciConfig, secret, apiUrl } =
await requireScreenCISecret(configPath)
const url = new URL(`${apiUrl}/cli/project-info`)
url.searchParams.set('projectName', screenciConfig.projectName)
const res = await fetch(url.toString(), {
headers: {
'X-ScreenCI-Secret': secret,
},
})
if (!res.ok) {
const text = await res.text()
throw new Error(
`Failed to fetch project info: ${res.status} ${text}${hint401(res.status, secret)}`
)
}
return (await res.json()) as ProjectInfoResponse
}
async function printProjectInfo(configPath?: string): Promise<void> {
const info = await fetchProjectInfo(configPath)
process.stdout.write(`${JSON.stringify(info, null, 2)}\n`)
}
async function updateVideoVisibility(
videoId: string,
isPublic: boolean,
configPath?: string
): Promise<void> {
const { secret, apiUrl } = await requireScreenCISecret(configPath)
const method = isPublic ? 'PUT' : 'DELETE'
const res = await fetch(`${apiUrl}/cli/public-video/${videoId}`, {
method,
headers: {
'X-ScreenCI-Secret': secret,
},
})
if (!res.ok) {
const text = await res.text()
throw new Error(
`Failed to ${isPublic ? 'make public' : 'make private'}: ${res.status} ${text}${hint401(res.status, secret)}`
)
}
logger.info(`${isPublic ? 'Made public' : 'Made private'}: ${videoId}`)
}
async function persistScreenCISecret(
envFilePath: string,
secret: string
): Promise<void> {
const nextLine = `SCREENCI_SECRET=${secret}`
try {
const existing = await readFile(envFilePath, 'utf-8')
const lines = existing === '' ? [] : existing.split(/\r?\n/)
const firstSecretIndex = lines.findIndex((line) =>
line.startsWith('SCREENCI_SECRET=')
)
const linesWithoutSecret = lines.filter(
(line) => !line.startsWith('SCREENCI_SECRET=')
)
const finalLines =
firstSecretIndex >= 0
? [
...linesWithoutSecret.slice(0, firstSecretIndex),
nextLine,
...linesWithoutSecret.slice(firstSecretIndex),
]
: [...linesWithoutSecret, nextLine]
let nextContent = finalLines.join('\n')
if (!nextContent.endsWith('\n')) nextContent += '\n'
await writeFile(envFilePath, nextContent)
return
} catch (err) {
if (!isMissingFileError(err)) throw err
}
await writeFile(envFilePath, `${nextLine}\n`)
}
function getLinkSessionFilePath(projectDir: string): string {
return resolve(projectDir, '.screenci', SCREENCI_LINK_SESSION_FILE)
}
async function readPersistedLinkSessionSpec(
specPath: string
): Promise<PersistedLinkSessionSpec | null> {
try {
const raw = await readFile(specPath, 'utf-8')
return JSON.parse(raw) as PersistedLinkSessionSpec
} catch (error) {
if (!isMissingFileError(error)) {
logger.warn(`Ignoring invalid stored link session at ${specPath}.`)
rmSync(specPath, { force: true })
}
return null
}
}
async function writePersistedLinkSessionSpec(
specPath: string,
spec: PersistedLinkSessionSpec
): Promise<void> {
mkdirSync(dirname(specPath), { recursive: true })
await writeFile(specPath, `${JSON.stringify(spec, null, 2)}\n`)
}
function deletePersistedLinkSessionSpec(specPath: string): void {
rmSync(specPath, { force: true })
}
function isStoredLinkSessionReusable(
spec: PersistedLinkSessionSpec,
options: {
environment: ScreenCIEnvironment
resolvedConfigPath?: string
envFilePath: string
}
): boolean {
return (
spec.environment === options.environment &&
spec.envFilePath === options.envFilePath &&
spec.resolvedConfigPath === options.resolvedConfigPath &&
spec.expiresAt > new Date().toISOString()
)
}
async function createLinkSessionSpec(options: {
apiUrl: string
appUrl: string
environment: ScreenCIEnvironment
resolvedConfigPath?: string
envFilePath: string
}): Promise<PersistedLinkSessionSpec> {
const response = await fetch(`${options.apiUrl}/cli-link/session`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(`Failed to create link session: ${response.status} ${text}`)
}
const body = (await response.json()) as {
token: string
createdAt: string
expiresAt: string
}
return {
token: body.token,
appUrl: `${options.appUrl}/cli-auth?session=${encodeURIComponent(body.token)}`,
pollUrl: `${options.apiUrl}/cli-link/session?token=${encodeURIComponent(body.token)}`,
createdAt: body.createdAt,
expiresAt: body.expiresAt,
environment: options.environment,
...(options.resolvedConfigPath
? { resolvedConfigPath: options.resolvedConfigPath }
: {}),
envFilePath: options.envFilePath,
}
}
async function pollLinkSession(
spec: PersistedLinkSessionSpec
): Promise<{ status: LinkSessionStatus; secret?: string }> {
for (;;) {
const response = await fetch(spec.pollUrl)
const body = (await response.json()) as {
status?: LinkSessionStatus
secret?: string
}
const status = body.status ?? 'invalid'
if (status === 'completed' && body.secret) {
return { status, secret: body.secret }
}
if (status === 'expired' || status === 'consumed' || status === 'invalid') {
return { status }
}
if (new Date().toISOString() >= spec.expiresAt) {
return { status: 'expired' }
}
await new Promise((resolveDelay) =>
setTimeout(resolveDelay, SCREENCI_LINK_SESSION_POLL_INTERVAL_MS)
)
}
}
export async function ensureScreenciSecret(
resolvedConfigPath?: string
): Promise<string | undefined> {
const existingSecret = process.env.SCREENCI_SECRET
if (existingSecret) return existingSecret
try {
const environment = getScreenCIEnvironment()
const apiUrl = getCliLinkSessionApiUrl()
const appUrl = getDevFrontendUrl()
const envFilePath = resolvedConfigPath
? await resolveProjectEnvFilePath(resolvedConfigPath)
: resolve(process.cwd(), '.env')
const projectDir = resolvedConfigPath
? dirname(resolvedConfigPath)
: process.cwd()
const specPath = getLinkSessionFilePath(projectDir)
const linkSessionContext = {
environment,
envFilePath,
...(resolvedConfigPath ? { resolvedConfigPath } : {}),
}
for (;;) {
const storedSpec = await readPersistedLinkSessionSpec(specPath)
const spec =
storedSpec &&
isStoredLinkSessionReusable(storedSpec, linkSessionContext)
? storedSpec
: await createLinkSessionSpec({
apiUrl,
appUrl,
...linkSessionContext,
})
if (spec !== storedSpec) {
await writePersistedLinkSessionSpec(specPath, spec)
}
logger.info(
`Open this link to sign in and connect the CLI:\n${pc.cyan(spec.appUrl)}\n`
)
const result = await pollLinkSession(spec)
if (result.status === 'completed' && result.secret) {
process.env.SCREENCI_SECRET = result.secret
await persistScreenCISecret(envFilePath, result.secret)
deletePersistedLinkSessionSpec(specPath)
logger.info(`Successfully saved SCREENCI_SECRET to ${envFilePath}`)
return result.secret
}
deletePersistedLinkSessionSpec(specPath)
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.warn(`Authentication failed: ${msg}`)
logger.info(
`You can add SCREENCI_SECRET manually to ${resolvedConfigPath ? await resolveProjectEnvFilePath(resolvedConfigPath) : '.env'} later. Get it from ${getScreenCISecretsUrl()}.`
)
logScreenCISecretGuide()
return undefined
}
}
export async function main() {
if (process.argv.length <= 2) {
logger.error('Error: No command provided')
logger.error(
'Available commands: record, test, info, make-public, make-private, init'
)
process.exit(1)
}
const program = new Command()
const defaultPackageManager = determinePackageManager()
program.name('screenci')
program.exitOverride()
// record command — playwright args pass through as-is
program
.command('record [playwrightArgs...]')
.description('Record videos using Playwright')
.option('-v, --verbose', 'verbose output')
.allowUnknownOption(true)
.action(async () => {
const parsed = parseRecordCliArgs(getSubcommandArgv('record'))
let playwrightFailure: Error | null = null
try {
await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose)
} catch (error) {
if (!(error instanceof Error)) throw error
if (error.message.startsWith('Playwright exited with code ')) {
playwrightFailure = new RecordFailureHintError(error)
} else {
throw new RecordFailureHintError(error)
}
}
if (process.env.SCREENCI_RECORDING === 'true') return
// After recording, upload results to API if configured
const resolvedConfigPath = findScreenCIConfig(parsed.configPath)
if (resolvedConfigPath) {
try {
const screenciConfig =
await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath)
loadEnvFile(
screenciConfig.envFile
? resolve(dirname(resolvedConfigPath), screenciConfig.envFile)
: resolve(dirname(resolvedConfigPath), '.env'),
true
)
const apiUrl = getDevBackendUrl()
const appUrl = getDevFrontendUrl()
const secret = process.env.SCREENCI_SECRET
const uploadPolicy = resolveRecordUploadPolicy(screenciConfig)
const configDir = dirname(resolvedConfigPath)
const screenciDir = resolve(configDir, '.screenci')
const completedRecordingCount =
await countCompletedRecordings(screenciDir)
if (playwrightFailure !== null && completedRecordingCount === 0) {
logger.info('All recordings failed.')
} else if (!secret) {
logger.info(
`No SCREENCI_SECRET configured for uploads. Rerun ${getSuggestedScreenciCommand('record')} or add it to the project env file.`
)
} else if (
playwrightFailure !== null &&
uploadPolicy === 'all-or-nothing'
) {
logger.info(
'Some recordings failed, skipping upload because record.upload is "all-or-nothing".'
)
} else {
if (playwrightFailure !== null && uploadPolicy === 'passed-only') {
logger.warn(
'Some recordings failed, uploading successful videos only.'
)
}
let uploadResult: {
projectId: string | null
recordId: string | null
hadFailures: boolean
failedVideoNames: string[]
failedVideoMessages: Array<{ videoName: string; message: string }>
} = {
projectId: null,
recordId: null,
hadFailures: false,
failedVideoNames: [],
failedVideoMessages: [],
}
try {
uploadResult = await uploadRecordings(
screenciDir,
screenciConfig.projectName,
apiUrl,
secret
)
} catch (err) {
if (isUploadCancelledError(err)) {
process.exit(130)
}
throw err
}
const {
projectId,
recordId,
hadFailures,
failedVideoNames,
failedVideoMessages,
} = uploadResult
if (recordId !== null && projectId !== null) {
const recordUrl = `${appUrl}/record/${recordId}`
await writeGitHubProjectOutput(recordUrl)
logger.info('')
logger.info(
playwrightFailure !== null
? 'Recording partially succeeded, rendering in progress. Results available at:'
: 'Recording finished, rendering in progress. Results available at:'
)
logger.info(pc.cyan(recordUrl))
} else if (projectId !== null) {
const projectUrl = `${appUrl}/project/${projectId}`
await writeGitHubProjectOutput(projectUrl)
logger.info('')
logger.info(
playwrightFailure !== null
? 'Recording partially succeeded, rendering in progress. Results available at:'
: 'Recording finished, rendering in progress. Results available at:'
)
logger.info(pc.cyan(projectUrl))
}
if (hadFailures) {
for (const failedVideo of failedVideoMessages) {
logger.warn(
formatFailedVideoMessage(
failedVideo.videoName,
failedVideo.message
)
)
}
logger.warn(
`Not all recordings succeeded to upload. Failed videos: ${failedVideoNames.join(', ') || 'unknown'}. Some videos may be missing from the project.`
)
if (playwrightFailure === null) {
throw new PartialUploadError()
}
}
}
} catch (err) {
if (isPartialUploadError(err)) {
throw err
}
logger.warn('Failed to load config for upload:', err)
}
}
if (playwrightFailure !== null) {
throw playwrightFailure
}
})
program
.command('test [playwrightArgs...]')
.description('Run Playwright test with screenci.config.ts')
.option(
'--mock-record',
'keep recording-style cursor animation and sleeps during screenci test'
)
.option('-v, --verbose', 'verbose output')
.allowUnknownOption(true)
.action(async () => {
const parsed = parseConfigCliArgs(getSubcommandArgv('test'))
let configMockRecord = false
const resolvedConfigPath = findScreenCIConfig(parsed.configPath)
if (resolvedConfigPath) {
try {
const screenciConfig =
await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath)
configMockRecord = screenciConfig.test?.mockRecord ?? false
if (screenciConfig.envFile) {
const envFilePath = resolve(
dirname(resolvedConfigPath),
screenciConfig.envFile
)
loadEnvFile(envFilePath, true)
}
} catch (err) {
logger.warn('Failed to load config for test env:', err)
}
}
await run(
'test',
parsed.otherArgs,
parsed.configPath,
parsed.verbose,
parsed.mockRecord || configMockRecord
)
if (process.env.SCREENCI_RECORDING === 'true') return
const recordCommand = getSuggestedScreenciCommand('record')
logger.info(
`Tests passed. Run ${pc.cyan(recordCommand)} to render the videos.`
)
})
program
.command('info')
.description('Print remote project info as JSON')
.option('-c, --config <path>', 'path to screenci.config.ts')
.action(async (options: Record<string, unknown>) => {
await printProjectInfo(options['config'] as string | undefined)
})
program
.command('make-public <id>')
.description(
'Enable public URLs for a video; get the id from screenci info'
)
.option('-c, --config <path>', 'path to screenci.config.ts')
.action(async (id: string, options: Record<string, unknown>) => {
await updateVideoVisibility(
id,
true,
options['config'] as string | undefined
)
})
program
.command('make-private <id>')
.description(
'Disable public URLs for a video; get the id from screenci info'
)
.option('-c, --config <path>', 'path to screenci.config.ts')
.action(async (id: string, options: Record<string, unknown>) => {
await updateVideoVisibility(
id,
false,
options['config'] as string | undefined
)
})
// init command
program
.command('init [name]')
.description('Initialize a new screenci project')
.option(
'--agent <name>',
'target agent for skills install, e.g. opencode. Supported agents: https://github.com/vercel-labs/skills#supported-agents'
)
.option(
'--package-manager <manager>',
`package manager to use: npm, pnpm, or yarn 2+ (default: ${defaultPackageManager})`
)
.option('-y, --yes', 'accept init defaults')
.option('-v, --verbose', 'verbose output')
.action(
async (name: string | undefined, options: Record<string, unknown>) => {
const agent = options['agent'] as string | undefined
await runInit(name, {
verbose: (options['verbose'] as boolean | undefined) ?? false,
yes: (options['yes'] as boolean | undefined) ?? false,
packageManager: parsePackageManager(
options['packageManager'] as string | undefined,
process.env['SCREENCI_INIT_CWD'] ?? process.cwd()
),
...(agent !== undefined ? { agent } : {}),
})
}
)
try {
await program.parseAsync(process.argv)
} catch (err) {
if (err instanceof CommanderError) {
if (err.code === 'commander.unknownCommand') {
const unknownCmd = process.argv[2] ?? ''
logger.error(`Unknown command: ${unknownCmd}`)
process.exit(1)
}
if (err.code === 'commander.optionMissingArgument') {
if (
err.message.includes('--config') ||
err.message.includes('-c, --config') ||
err.message.includes("'-c'")
) {
logger.error('Error: --config requires a path argument')
process.exit(1)
}
logger.error(`Error: ${err.message}`)
process.exit(1)
}
if (
err.code === 'commander.help' ||
err.code === 'commander.helpDisplayed'
) {
return
}
logger.error(`Error: ${err.message}`)
process.exit(1)
return
}
throw err
}
}
function getSubcommandArgv(command: string): string[] {
const argv = process.argv.slice(2)
const commandIndex = argv.indexOf(command)
return commandIndex === -1 ? [] : argv.slice(commandIndex + 1)
}
function parseRecordCliArgs(args: string[]): {
configPath: string | undefined
verbose: boolean
otherArgs: string[]
} {
let configPath: string | undefined
let verbose = false
const otherArgs: string[] = []
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg === undefined) continue
if (arg === '--config' || arg === '-c') {
const nextArg = args[i + 1]
if (nextArg === undefined) {
logger.error('Error: --config requires a path argument')
process.exit(1)
}
configPath = nextArg
i++
} else if (arg === '--verbose' || arg === '-v') {
verbose = true
} else {
otherArgs.push(arg)
}
}
return {
configPath,
verbose,
otherArgs,
}
}
function parseConfigCliArgs(args: string[]): {
configPath: string | undefined
verbose: boolean
mockRecord: boolean
otherArgs: string[]
} {
let configPath: string | undefined
let verbose = false
let mockRecord = false
const otherArgs: string[] = []
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg === undefined) continue
if (arg === '--config' || arg === '-c') {
const nextArg = args[i + 1]
if (nextArg === undefined) {
logger.error('Error: --config requires a path argument')
process.exit(1)
}
configPath = nextArg
i++
} else if (arg === '--verbose' || arg === '-v') {
verbose = true
} else if (arg === '--mock-record') {
mockRecord = true
} else {
otherArgs.push(arg)
}
}
return { configPath, verbose, mockRecord, otherArgs }
}
function validateArgs(args: string[]): void {
const disallowedFlags = ['--retries']
for (const arg of args) {
if (arg === undefined) continue
// Check if it's a disallowed flag
if (disallowedFlags.includes(arg)) {
throw new Error(
`Flag "${arg}" is not supported by screenci. ` +
'screenci forces retries to 0 for proper video recording.'
)
}
// Check if it's a --retries=N format
if (arg.startsWith('--retries=')) {
throw new Error(
`Flag "${arg}" is not supported by screenci. ` +
'screenci forces retries to 0 for proper video recording.'
)
}
}
}
async function run(
command: 'record' | 'test',
additionalArgs: string[],
customConfigPath?: string,
verbose = false,
mockRecord = false
) {
const configPath = findScreenCIConfig(customConfigPath)
if (!configPath) {
const errorMsg = customConfigPath
? `Error: Config file not found: ${customConfigPath}`
: 'Error: screenci.config.ts not found in current directory'
logger.error(errorMsg)
process.exit(1)
}
if (command === 'test' || process.env.SCREENCI_RECORDING !== 'true') {
await loadEnvFileFromConfigSource(configPath, false)
}
// Only validate args for record command
if (command === 'record') {
if (!process.env.SCREENCI_SECRET) {
await requireScreenCISecret(configPath)
}
validateArgs(additionalArgs)
const screenciDir = resolve(dirname(configPath), '.screenci')
clearRecordingDirectories(screenciDir)
}
const envForChild = { ...process.env }
await validateUniqueDiscoveredTestTitles(configPath, additionalArgs, {
...envForChild,
SCREENCI_CONFIG_DIR: dirname(configPath),
...(command === 'record' ? { SCREENCI_RECORDING: 'true' } : {}),
...(command === 'test' && !mockRecord
? { [SCREENCI_DISABLE_RECORDING_TIMINGS_ENV]: 'true' }
: {}),
...(command === 'test' && mockRecord
? { [SCREENCI_MOCK_RECORD_ENV]: 'true' }
: {}),
})
if (verbose && process.env.SCREENCI_RECORDING !== 'true') {
logger.info(`Using config: ${configPath}`)
}
const playwrightArgs = ['test', '--config', configPath, ...additionalArgs]
const spawnSpec = resolvePlaywrightSpawnSpec(
playwrightArgs,
dirname(configPath)
)
const child = spawn(spawnSpec.command, spawnSpec.args, {
stdio: 'inherit',
...(process.platform !== 'win32' ? { detached: true } : {}),
...(spawnSpec.shell !== undefined ? { shell: spawnSpec.shell } : {}),
...(spawnSpec.windowsVerbatimArguments !== undefined
? {
windowsVerbatimArguments: spawnSpec.windowsVerbatimArguments,
}
: {}),
env: {
...envForChild,
SCREENCI_CONFIG_DIR: dirname(configPath),
// Enable recording only for record command
...(command === 'record' ? { SCREENCI_RECORDING: 'true' } : {}),
...(command === 'test' && !mockRecord
? { [SCREENCI_DISABLE_RECORDING_TIMINGS_ENV]: 'true' }
: {}),
...(command === 'test' && mockRecord
? { [SCREENCI_MOCK_RECORD_ENV]: 'true' }
: {}),
},
})
const childSignals = forwardChildSignals(child, `screenci ${command}`, {
killTree: process.platform !== 'win32',
exitParentOnForward: true,
})
return new Promise<void>((resolve, reject) => {
child.on('close', (code, signal) => {
void (async () => {
const forwardedSignal = childSignals.getForwardedSignal()
childSignals.cleanup()
if (forwardedSignal) {
process.kill(process.pid, forwardedSignal)
return
}
if (signal) {
process.kill(process.pid, signal)
return
}
if (code === 0) {
resolve()
} else {
reject(new Error(`Playwright exited with code ${code}`))
}
})().catch(reject)
})
child.on('error', (err) => {
childSignals.cleanup()
reject(err)
})
})
}
function logRecordFailureHint(): void {
logger.info('')
logger.info(
`If ${pc.cyan('screenci test')} works but ${pc.cyan(
'screenci record'
)} fails, try ${pc.cyan('screenci test --mock-record')}.`
)
logger.info(`More info: ${pc.cyan(SCREENCI_MOCK_RECORD_DOCS_URL)}`)
}
export function logCliError(error: unknown): void {
if (isPartialUploadError(error)) {
return
}
const errorToLog = isRecordFailureHintError(error) ? error.cause : error
const message =
errorToLog instanceof Error ? errorToLog.message : String(errorToLog)
logger.error(message)
if (isRecordFailureHintError(error)) {
logRecordFailureHint()
}
}
// Only run if this file is being executed directly
// Check if this module is the main module (handles symlinks properly)
const currentFile = fileURLToPath(import.meta.url)
const mainFile = process.argv[1] ? realpathSync(process.argv[1]) : null
const currentRealFile = realpathSync(currentFile)
if (
mainFile &&
(currentFile === mainFile ||
currentRealFile === mainFile ||
currentFile === realpathSync(mainFile))
) {
main().catch((error) => {
logCliError(error)
process.exit(1)
})
}