2026-06-11 16:56:28 +03:00
import { describe , it , expect , vi , beforeEach , afterEach } from 'vitest'
2026-06-25 01:11:01 +03:00
import { createHash } from 'node:crypto'
2026-06-11 16:56:28 +03:00
import type { ChildProcess } from 'child_process'
import { EventEmitter } from 'events'
import { Readable } from 'stream'
import { stripVTControlCharacters } from 'util'
import { logger } from './src/logger.js'
2026-07-04 18:13:26 +03:00
// Not a static top-level import: `./src/anonSession.js` imports `fs`, and a
// static import here would resolve it before this file's own mock* variables
// (below) initialize, breaking the `vi.mock('fs', ...)` hoisting below. Each
// test instead destructures `secretCredential` off the same dynamic
// `await import('./cli')` it already uses for the function under test.
2026-06-11 16:56:28 +03:00
const mockSpawn = vi . fn ( )
const mockExec = vi . fn ( )
const mockExistsSync = vi . fn ( )
const mockRealpathSync = vi . fn ( ( path : string ) = > path )
const mockMkdirSync = vi . fn ( )
const mockRmSync = vi . fn ( )
2026-07-11 21:52:29 +03:00
const mockRenameSync = vi . fn ( )
const mockStatSync = vi . fn (
( ) = > undefined as undefined | { isDirectory : ( ) = > boolean }
)
2026-06-11 16:56:28 +03:00
const mockReaddirSync = vi . fn ( ( ) = > [ ] as string [ ] )
const mockReadFileSync = vi . fn ( )
const mockReaddir = vi . fn ( )
const mockReadFile = vi . fn ( )
const mockStat = vi . fn ( )
const mockCreateReadStream = vi . fn ( )
const mockAppendFile = vi . fn ( )
const mockWriteFile = vi . fn ( )
const mockMkdir = vi . fn ( )
2026-07-06 00:54:35 +03:00
const mockRm = vi . fn ( )
2026-06-11 16:56:28 +03:00
const mockInput = vi . fn ( )
const mockConfirm = vi . fn ( )
const mockCreateHttpServer = vi . fn ( )
const mockFetch = vi . fn ( )
const mockSpinner = {
start : vi.fn ( ) . mockReturnThis ( ) ,
succeed : vi.fn ( ) . mockReturnThis ( ) ,
fail : vi.fn ( ) . mockReturnThis ( ) ,
stop : vi.fn ( ) . mockReturnThis ( ) ,
}
const mockOra = vi . fn ( ) . mockReturnValue ( mockSpinner )
function expectNpmDevInstalls (
mockSpawn : ReturnType < typeof vi.fn > ,
cwd : string ,
screenciVersion = '0.0.32' ,
includePlaywrightCli = true
) {
const npmInstallCalls = mockSpawn . mock . calls . filter (
( call : unknown [ ] ) = >
call [ 0 ] === 'npm' &&
Array . isArray ( call [ 1 ] ) &&
call [ 1 ] [ 0 ] === 'install' &&
call [ 1 ] [ 1 ] === '--save-dev' &&
call [ 2 ] &&
typeof call [ 2 ] === 'object' &&
'cwd' in ( call [ 2 ] as Record < string , unknown > ) &&
( call [ 2 ] as { cwd? : string } ) . cwd === cwd &&
'stdio' in ( call [ 2 ] as Record < string , unknown > ) &&
( call [ 2 ] as { stdio? : string } ) . stdio === 'pipe'
)
const expectedPackages = [
` @playwright/test@^1.59.0 ` ,
` screenci@ ${ screenciVersion } ` ,
'@types/node@^25.9.1' ,
. . . ( includePlaywrightCli ? [ '@playwright/cli@latest' ] : [ ] ) ,
]
expect ( npmInstallCalls ) . toEqual (
expect . arrayContaining (
expectedPackages . map ( ( pkg ) = > [
'npm' ,
[ 'install' , '--save-dev' , pkg ] ,
expect . objectContaining ( { cwd , stdio : 'pipe' } ) ,
] )
)
)
}
function expectPnpmDevInstalls (
mockSpawn : ReturnType < typeof vi.fn > ,
cwd : string ,
screenciVersion = '0.0.32' ,
includePlaywrightCli = true
) {
const pnpmInstallCalls = mockSpawn . mock . calls . filter (
( call : unknown [ ] ) = >
call [ 0 ] === 'pnpm' &&
Array . isArray ( call [ 1 ] ) &&
call [ 1 ] [ 0 ] === 'add' &&
call [ 1 ] [ 1 ] === '--save-dev' &&
call [ 2 ] &&
typeof call [ 2 ] === 'object' &&
'cwd' in ( call [ 2 ] as Record < string , unknown > ) &&
( call [ 2 ] as { cwd? : string } ) . cwd === cwd &&
'stdio' in ( call [ 2 ] as Record < string , unknown > ) &&
( call [ 2 ] as { stdio? : string } ) . stdio === 'pipe'
)
const expectedPackages = [
[ 'add' , '--save-dev' , ` @playwright/test@^1.59.0 ` ] ,
[
'add' ,
'--save-dev' ,
'--allow-build=ffmpeg-static' ,
` screenci@ ${ screenciVersion } ` ,
] ,
[ 'add' , '--save-dev' , '@types/node@^25.9.1' ] ,
. . . ( includePlaywrightCli
? [ [ 'add' , '--save-dev' , '@playwright/cli@latest' ] ]
: [ ] ) ,
]
expect ( pnpmInstallCalls ) . toEqual (
expect . arrayContaining (
expectedPackages . map ( ( args ) = > [
'pnpm' ,
args ,
expect . objectContaining ( { cwd , stdio : 'pipe' } ) ,
] )
)
)
}
function expectYarnDevInstalls (
mockSpawn : ReturnType < typeof vi.fn > ,
cwd : string ,
screenciVersion = '0.0.32' ,
includePlaywrightCli = true
) {
const yarnInstallCalls = mockSpawn . mock . calls . filter (
( call : unknown [ ] ) = >
call [ 0 ] === 'yarn' &&
Array . isArray ( call [ 1 ] ) &&
call [ 1 ] [ 0 ] === 'add' &&
call [ 1 ] [ 1 ] === '--dev' &&
call [ 2 ] &&
typeof call [ 2 ] === 'object' &&
'cwd' in ( call [ 2 ] as Record < string , unknown > ) &&
( call [ 2 ] as { cwd? : string } ) . cwd === cwd &&
'stdio' in ( call [ 2 ] as Record < string , unknown > ) &&
( call [ 2 ] as { stdio? : string } ) . stdio === 'pipe'
)
const expectedPackages = [
[ 'add' , '--dev' , ` @playwright/test@^1.59.0 ` ] ,
[ 'add' , '--dev' , ` screenci@ ${ screenciVersion } ` ] ,
[ 'add' , '--dev' , '@types/node@^25.9.1' ] ,
. . . ( includePlaywrightCli
? [ [ 'add' , '--dev' , '@playwright/cli@latest' ] ]
: [ ] ) ,
]
expect ( yarnInstallCalls ) . toEqual (
expect . arrayContaining (
expectedPackages . map ( ( args ) = > [
'yarn' ,
args ,
expect . objectContaining ( { cwd , stdio : 'pipe' } ) ,
] )
)
)
}
vi . mock ( 'child_process' , ( ) = > ( {
spawn : mockSpawn ,
exec : mockExec ,
createReadStream : mockCreateReadStream ,
default : {
spawn : mockSpawn ,
exec : mockExec ,
createReadStream : mockCreateReadStream ,
} ,
} ) )
vi . mock ( 'fs' , ( ) = > ( {
createReadStream : mockCreateReadStream ,
existsSync : mockExistsSync ,
realpathSync : mockRealpathSync ,
mkdirSync : mockMkdirSync ,
rmSync : mockRmSync ,
2026-07-11 21:52:29 +03:00
renameSync : mockRenameSync ,
statSync : mockStatSync ,
2026-06-11 16:56:28 +03:00
readdirSync : mockReaddirSync ,
readFileSync : mockReadFileSync ,
default : {
createReadStream : mockCreateReadStream ,
existsSync : mockExistsSync ,
realpathSync : mockRealpathSync ,
mkdirSync : mockMkdirSync ,
rmSync : mockRmSync ,
2026-07-11 21:52:29 +03:00
renameSync : mockRenameSync ,
statSync : mockStatSync ,
2026-06-11 16:56:28 +03:00
readdirSync : mockReaddirSync ,
readFileSync : mockReadFileSync ,
} ,
} ) )
vi . mock ( 'fs/promises' , ( ) = > ( {
appendFile : mockAppendFile ,
2026-07-06 00:54:35 +03:00
rm : mockRm ,
2026-06-11 16:56:28 +03:00
readdir : mockReaddir ,
readFile : mockReadFile ,
stat : mockStat ,
writeFile : mockWriteFile ,
mkdir : mockMkdir ,
default : {
appendFile : mockAppendFile ,
2026-07-06 00:54:35 +03:00
rm : mockRm ,
2026-06-11 16:56:28 +03:00
readdir : mockReaddir ,
readFile : mockReadFile ,
stat : mockStat ,
writeFile : mockWriteFile ,
mkdir : mockMkdir ,
} ,
} ) )
vi . mock ( '@inquirer/prompts' , ( ) = > ( {
input : mockInput ,
confirm : mockConfirm ,
} ) )
vi . mock ( 'ora' , ( ) = > ( {
default : mockOra ,
} ) )
vi . mock ( 'http' , ( ) = > ( {
createServer : mockCreateHttpServer ,
default : { createServer : mockCreateHttpServer } ,
} ) )
describe ( 'CLI' , ( ) = > {
let mockChildProcess : EventEmitter
let loggerErrorSpy : ReturnType < typeof vi.spyOn >
let loggerInfoSpy : ReturnType < typeof vi.spyOn >
let loggerWarnSpy : ReturnType < typeof vi.spyOn >
let processExitSpy : ReturnType < typeof vi.spyOn >
let loadEnvFileSpy : ReturnType < typeof vi.spyOn > | undefined
let originalArgv : string [ ]
let originalEnv : NodeJS.ProcessEnv
let originalFetch : typeof global . fetch
let originalLoadEnvFile : ( ( path : string | URL ) = > void ) | undefined
beforeEach ( ( ) = > {
// Reset all mocks (clearAllMocks only clears call history, not Once queues;
// mockReset also clears return values/implementations including Once queue)
vi . clearAllMocks ( )
mockSpawn . mockReset ( )
mockAppendFile . mockResolvedValue ( undefined )
mockWriteFile . mockResolvedValue ( undefined )
mockMkdir . mockResolvedValue ( undefined )
2026-07-06 00:54:35 +03:00
mockRm . mockResolvedValue ( undefined )
2026-06-11 16:56:28 +03:00
mockReaddir . mockResolvedValue ( [ ] )
mockReadFileSync . mockImplementation ( ( ) = > {
if ( process . env . VITE_APP_BASE_URL === undefined ) {
process . env . VITE_APP_BASE_URL = 'https://env-file.example.com'
}
return 'VITE_APP_BASE_URL=https://env-file.example.com\n'
} )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
if ( String ( path ) . endsWith ( 'screenci.config.ts' ) ) {
return "export default defineConfig({ projectName: 'Test Project' })"
}
if ( String ( path ) . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
return ''
} )
mockStat . mockResolvedValue ( { size : 4 } )
2026-07-11 21:52:29 +03:00
mockStatSync . mockReturnValue ( undefined )
mockReaddirSync . mockReturnValue ( [ ] as unknown as string [ ] )
2026-06-11 16:56:28 +03:00
mockCreateReadStream . mockImplementation ( ( ) = > {
const stream = new Readable ( { read() { } } )
process . nextTick ( ( ) = > {
stream . push ( 'data' )
stream . push ( null )
} )
return stream
} )
// Default inquirer responses
mockInput . mockImplementation (
async ( options ? : { default ? : string } ) = > options ? . default ? ? ''
)
mockConfirm . mockResolvedValue ( false )
// Restore ora mock return value after clearAllMocks
mockOra . mockReturnValue ( mockSpinner )
mockSpinner . start . mockReturnThis ( )
mockSpinner . succeed . mockReturnThis ( )
mockSpinner . fail . mockReturnThis ( )
mockSpinner . stop . mockReturnThis ( )
// Store original values
originalArgv = process . argv
originalEnv = { . . . process . env }
originalFetch = global . fetch
originalLoadEnvFile = (
process as NodeJS . Process & {
loadEnvFile ? : ( path : string | URL ) = > void
}
) . loadEnvFile
delete process . env . npm_config_user_agent
// Mock child process (unref needed for openBrowser's detached spawn)
mockChildProcess = Object . assign ( new EventEmitter ( ) , {
unref : vi.fn ( ) ,
stdout : new EventEmitter ( ) ,
stderr : new EventEmitter ( ) ,
} )
mockSpawn . mockReturnValue ( mockChildProcess as unknown as ChildProcess )
// Mock file system
mockExistsSync . mockReturnValue ( true )
// Default http server mock: does not resolve (login not triggered by default)
mockCreateHttpServer . mockReturnValue ( {
listen : vi.fn ( ) ,
close : vi.fn ( ) ,
address : vi.fn ( ) . mockReturnValue ( { port : 12345 } ) ,
on : vi.fn ( ) ,
} )
// Mock logger methods
loggerErrorSpy = vi . spyOn ( logger , 'error' ) . mockImplementation ( ( ) = > { } )
loggerInfoSpy = vi . spyOn ( logger , 'info' ) . mockImplementation ( ( ) = > { } )
loggerWarnSpy = vi . spyOn ( logger , 'warn' ) . mockImplementation ( ( ) = > { } )
// Mock process.exit
processExitSpy = vi . spyOn ( process , 'exit' ) . mockImplementation ( ( ( ) = > {
throw new Error ( 'process.exit called' )
} ) as unknown as ( code? : string | number | null | undefined ) = > never )
if ( typeof originalLoadEnvFile === 'function' ) {
loadEnvFileSpy = vi
. spyOn (
process as NodeJS . Process & {
loadEnvFile ? : ( path : string | URL ) = > void
} ,
'loadEnvFile'
)
. mockImplementation ( ( path? : string | URL ) = > {
if (
String ( path ) . endsWith ( '.env' ) &&
process . env . VITE_APP_BASE_URL === undefined
) {
process . env . VITE_APP_BASE_URL = 'https://env-file.example.com'
}
} )
} else {
loadEnvFileSpy = undefined
; (
process as NodeJS . Process & {
loadEnvFile ? : ( path : string | URL ) = > void
}
) . loadEnvFile = undefined
}
global . fetch = mockFetch as typeof global . fetch
mockFetch . mockResolvedValue ( {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
} )
} )
afterEach ( ( ) = > {
// Restore original values
process . argv = originalArgv
process . env = originalEnv
global . fetch = originalFetch
; (
process as NodeJS . Process & {
loadEnvFile ? : ( path : string | URL ) = > void
}
) . loadEnvFile = originalLoadEnvFile
// Restore spies
loggerErrorSpy ? . mockRestore ( )
loggerInfoSpy ? . mockRestore ( )
loggerWarnSpy ? . mockRestore ( )
processExitSpy ? . mockRestore ( )
loadEnvFileSpy ? . mockRestore ( )
} )
2026-06-29 17:35:08 +03:00
describe ( 'clearRecordingDirectories' , ( ) = > {
2026-07-02 20:14:36 +03:00
it ( 'wipes per-recording directories but preserves the overlay cache' , async ( ) = > {
2026-06-29 17:35:08 +03:00
const { clearRecordingDirectories } = await import ( './cli' )
const dir = '/project/.screenci'
mockReaddirSync . mockReturnValue ( [
'My Video [en]' ,
'My Screenshot [en]' ,
'.overlay-cache' ,
] as unknown as string [ ] )
clearRecordingDirectories ( dir )
const removed = mockRmSync . mock . calls . map ( ( call ) = > call [ 0 ] as string )
expect ( removed ) . toContain ( '/project/.screenci/My Video [en]' )
expect ( removed ) . toContain ( '/project/.screenci/My Screenshot [en]' )
2026-07-02 20:14:36 +03:00
// The cross-run overlay cache survives the wipe so unchanged overlays are
// not re-rendered, re-encoded, and re-uploaded.
2026-06-29 17:35:08 +03:00
expect ( removed ) . not . toContain ( '/project/.screenci/.overlay-cache' )
} )
2026-07-04 19:45:29 +03:00
2026-07-11 21:52:29 +03:00
it ( 'keeps a recording dir data.json as last-data.json for the freshness check' , async ( ) = > {
2026-07-04 19:45:29 +03:00
const { clearRecordingDirectories } = await import ( './cli' )
const dir = '/project/.screenci'
2026-07-11 21:52:29 +03:00
mockReaddirSync . mockImplementation ( ( ( path : string ) = >
path === dir
? [ 'My Video [en]' ]
: [ 'data.json' , 'recording.mp4' ] ) as never )
mockStatSync . mockReturnValue ( { isDirectory : ( ) = > true } )
mockExistsSync . mockImplementation ( ( path : string ) = >
path . endsWith ( 'data.json' )
)
2026-07-07 16:05:06 +03:00
clearRecordingDirectories ( dir )
const removed = mockRmSync . mock . calls . map ( ( call ) = > call [ 0 ] as string )
2026-07-11 21:52:29 +03:00
// Media goes, the event data survives (renamed so the upload phase never
// mistakes it for a fresh recording).
expect ( removed ) . toContain (
'/project/.screenci/My Video [en]/recording.mp4'
)
expect ( removed ) . not . toContain ( '/project/.screenci/My Video [en]' )
expect ( mockRenameSync ) . toHaveBeenCalledWith (
'/project/.screenci/My Video [en]/data.json' ,
'/project/.screenci/My Video [en]/last-data.json'
)
2026-07-07 16:05:06 +03:00
} )
2026-07-07 21:09:55 +03:00
2026-07-11 21:52:29 +03:00
it ( 'preserves the anon trial token so one trial spans runs (cap/claim/graduate stay intact)' , async ( ) = > {
2026-07-07 21:09:55 +03:00
const { clearRecordingDirectories } = await import ( './cli' )
const dir = '/project/.screenci'
mockReaddirSync . mockReturnValue ( [
'My Video [en]' ,
2026-07-11 21:52:29 +03:00
'anon-session.json' ,
2026-07-07 21:09:55 +03:00
] as unknown as string [ ] )
clearRecordingDirectories ( dir )
const removed = mockRmSync . mock . calls . map ( ( call ) = > call [ 0 ] as string )
expect ( removed ) . toContain ( '/project/.screenci/My Video [en]' )
2026-07-11 21:52:29 +03:00
// Wiping this would mint a fresh trial every record, bypassing the
// one-record cap and breaking the claim / auto-graduate detection.
expect ( removed ) . not . toContain ( '/project/.screenci/anon-session.json' )
2026-07-07 21:09:55 +03:00
} )
} )
2026-07-06 00:54:35 +03:00
describe ( 'acquireRecordRunLock' , ( ) = > {
it ( 'refuses a fresh lock whose pid is still alive' , async ( ) = > {
const addSignalListener = vi . fn ( )
const removeSignalListener = vi . fn ( )
const { acquireRecordRunLock } = await import ( './cli' )
await expect (
acquireRecordRunLock ( '/repo/.screenci' , 'Test Project' , {
pid : 123 ,
clock : ( ) = > new Date ( '2026-07-05T10:05:00.000Z' ) ,
isPidAlive : ( pid ) = > pid === 456 ,
fs : {
mkdir : vi.fn ( ) . mockResolvedValue ( undefined ) ,
readFile : vi.fn ( ) . mockResolvedValue (
JSON . stringify ( {
pid : 456 ,
startedAt : '2026-07-05T10:00:00.000Z' ,
projectName : 'Other Project' ,
} )
) ,
writeFile : vi
. fn ( )
. mockRejectedValueOnce (
Object . assign ( new Error ( 'exists' ) , { code : 'EEXIST' } )
) ,
rm : vi.fn ( ) . mockResolvedValue ( undefined ) ,
} ,
addSignalListener ,
removeSignalListener ,
removeLockSync : vi.fn ( ) ,
} )
) . rejects . toThrow (
` Another 'screenci record' is in progress (pid 456, started 2026-07-05T10:00:00.000Z, project "Other Project"). Wait for it or remove .screenci/.record.lock. `
)
expect ( addSignalListener ) . not . toHaveBeenCalled ( )
expect ( removeSignalListener ) . not . toHaveBeenCalled ( )
} )
it ( 'reclaims a stale lock when the pid is dead' , async ( ) = > {
const mockWriteFile = vi
. fn ( )
. mockRejectedValueOnce (
Object . assign ( new Error ( 'exists' ) , { code : 'EEXIST' } )
)
. mockResolvedValueOnce ( undefined )
const mockRm = vi . fn ( ) . mockResolvedValue ( undefined )
const { acquireRecordRunLock } = await import ( './cli' )
const lock = await acquireRecordRunLock (
'/repo/.screenci' ,
'Test Project' ,
{
pid : 123 ,
clock : ( ) = > new Date ( '2026-07-05T10:05:00.000Z' ) ,
isPidAlive : ( ) = > false ,
fs : {
mkdir : vi.fn ( ) . mockResolvedValue ( undefined ) ,
readFile : vi.fn ( ) . mockResolvedValue (
JSON . stringify ( {
pid : 456 ,
startedAt : '2026-07-05T10:00:00.000Z' ,
projectName : 'Other Project' ,
} )
) ,
writeFile : mockWriteFile ,
rm : mockRm ,
} ,
addSignalListener : vi.fn ( ) ,
removeSignalListener : vi.fn ( ) ,
removeLockSync : vi.fn ( ) ,
}
)
expect ( mockRm ) . toHaveBeenCalledWith ( '/repo/.screenci/.record.lock' , {
force : true ,
} )
await lock . release ( )
} )
it ( 'reclaims a stale lock when it exceeds the max age' , async ( ) = > {
const mockWriteFile = vi
. fn ( )
. mockRejectedValueOnce (
Object . assign ( new Error ( 'exists' ) , { code : 'EEXIST' } )
)
. mockResolvedValueOnce ( undefined )
const mockRm = vi . fn ( ) . mockResolvedValue ( undefined )
const { acquireRecordRunLock } = await import ( './cli' )
const lock = await acquireRecordRunLock (
'/repo/.screenci' ,
'Test Project' ,
{
pid : 123 ,
clock : ( ) = > new Date ( '2026-07-05T20:05:00.000Z' ) ,
isPidAlive : ( ) = > true ,
fs : {
mkdir : vi.fn ( ) . mockResolvedValue ( undefined ) ,
readFile : vi.fn ( ) . mockResolvedValue (
JSON . stringify ( {
pid : 456 ,
startedAt : '2026-07-05T10:00:00.000Z' ,
projectName : 'Other Project' ,
} )
) ,
writeFile : mockWriteFile ,
rm : mockRm ,
} ,
addSignalListener : vi.fn ( ) ,
removeSignalListener : vi.fn ( ) ,
removeLockSync : vi.fn ( ) ,
}
)
expect ( mockRm ) . toHaveBeenCalledWith ( '/repo/.screenci/.record.lock' , {
force : true ,
} )
await lock . release ( )
} )
} )
2026-08-12 19:59:23 +03:00
describe ( 'export command' , ( ) = > {
2026-06-11 16:56:28 +03:00
beforeEach ( ( ) = > {
process . env . SCREENCI_SECRET = 'test-secret'
} )
2026-08-12 19:59:23 +03:00
afterEach ( ( ) = > {
// Export sets process.exitCode on failed/empty runs; never leak it into
// the test runner process.
process . exitCode = undefined
} )
2026-08-13 12:18:18 +03:00
it ( 'refuses an anonymous export before recording (exports are account-only)' , async ( ) = > {
2026-06-11 16:56:28 +03:00
delete process . env . SCREENCI_SECRET
2026-08-12 19:59:23 +03:00
process . argv = [ 'node' , 'cli.js' , 'export' ]
2026-07-04 18:13:26 +03:00
mockSpawn . mockImplementation ( ( ) = > {
process . nextTick ( ( ) = > mockChildProcess . emit ( 'close' , 0 ) )
return mockChildProcess as unknown as ChildProcess
} )
2026-06-18 13:52:20 +03:00
const { main } = await import ( './cli' )
2026-08-13 12:18:18 +03:00
// The anonymous trial is preview-only: export exits with the sign-up
// message before Playwright ever starts.
await expect ( main ( ) ) . rejects . toThrow ( 'process.exit called' )
2026-06-18 13:52:20 +03:00
2026-08-13 12:18:18 +03:00
expect ( processExitSpy ) . toHaveBeenCalledWith ( 1 )
// Nothing was recorded or uploaded: only the test-discovery pass ran.
expect (
mockFetch . mock . calls . some ( ( call ) = >
String ( call [ 0 ] ) . includes ( '/cli/upload/start' )
)
) . toBe ( false )
const errors = loggerErrorSpy . mock . calls . map ( ( call ) = >
stripVTControlCharacters ( String ( call [ 0 ] ) )
)
expect (
errors . some ( ( message ) = >
message . includes ( 'Exporting requires an account' )
)
) . toBe ( true )
2026-06-24 15:19:10 +03:00
} )
2026-06-11 16:56:28 +03:00
it ( 'loads SCREENCI_SECRET from the project .env when envFile is not configured' , async ( ) = > {
delete process . env . SCREENCI_SECRET
2026-08-12 19:59:23 +03:00
process . argv = [ 'node' , 'cli.js' , 'export' ]
2026-06-11 16:56:28 +03:00
if ( loadEnvFileSpy ) {
loadEnvFileSpy . mockImplementation ( ( path? : string | URL ) = > {
if ( String ( path ) === ` ${ process . cwd ( ) } /.env ` ) {
process . env . SCREENCI_SECRET = 'env-secret'
}
} )
} else {
mockReadFileSync . mockReturnValue ( 'SCREENCI_SECRET=env-secret\n' )
}
mockSpawn . mockImplementation ( ( ) = > {
process . nextTick ( ( ) = > mockChildProcess . emit ( 'close' , 0 ) )
return mockChildProcess as unknown as ChildProcess
} )
const { main } = await import ( './cli' )
await main ( )
if ( loadEnvFileSpy ) {
expect ( loadEnvFileSpy ) . toHaveBeenCalledWith ( ` ${ process . cwd ( ) } /.env ` )
} else {
expect ( mockReadFileSync ) . toHaveBeenCalledWith (
` ${ process . cwd ( ) } /.env ` ,
'utf8'
)
}
expect ( mockCreateHttpServer ) . not . toHaveBeenCalled ( )
} )
it ( 'should run Playwright locally for record command' , async ( ) = > {
2026-08-12 19:59:23 +03:00
process . argv = [ 'node' , 'cli.js' , 'export' ]
2026-06-11 16:56:28 +03:00
process . env . VITE_APP_BASE_URL = 'https://example.com'
mockSpawn . mockImplementation (
(
_command : string ,
_args : string [ ] ,
options ? : { env? : NodeJS.ProcessEnv }
) = > {
expect ( options ? . env ? . SCREENCI_RECORDING ) . toBe ( 'true' )
expect ( options ? . env ? . VITE_APP_BASE_URL ) . toBe ( 'https://example.com' )
process . nextTick ( ( ) = > mockChildProcess . emit ( 'close' , 0 ) )
return mockChildProcess as unknown as ChildProcess
}
)
const { main } = await import ( './cli' )
await main ( )
expect ( mockSpawn ) . toHaveBeenCalledWith (
process . execPath ,
expect . arrayContaining ( [
expect . stringContaining ( '@playwright/test/cli' ) ,
'test' ,
] ) ,
expect . objectContaining ( {
env : expect.objectContaining ( {
SCREENCI_RECORDING : 'true' ,
VITE_APP_BASE_URL : 'https://example.com' ,
} ) ,
2026-06-12 23:42:47 +03:00
stdio : 'inherit' ,
2026-06-11 16:56:28 +03:00
} )
)
} )
2026-07-06 00:54:35 +03:00
it ( 'fails the run without printing a results URL when only an unrelated sibling recording is uploadable' , async ( ) = > {
process . argv = [
'node' ,
'cli.js' ,
2026-08-12 19:59:23 +03:00
'export' ,
2026-07-06 00:54:35 +03:00
'--config' ,
'test-fixtures/record-upload.config.ts' ,
'--grep' ,
'code cut' ,
]
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'record-upload.config.ts' ) ) {
return "export default { projectName: 'Test Project' }"
}
if ( pathString . endsWith ( '/code-cut/data.json' ) ) {
return JSON . stringify ( {
events : [ ] ,
metadata : { videoName : 'Code Cut' } ,
} )
}
if ( pathString . endsWith ( '/styled-backgrounds/data.json' ) ) {
return JSON . stringify ( {
events : [ ] ,
metadata : { videoName : 'Styled Backgrounds' } ,
} )
}
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
return ''
} )
mockReaddir . mockResolvedValue ( [ 'code-cut' , 'styled-backgrounds' ] )
mockExistsSync . mockImplementation ( ( path : string ) = > {
if ( path . endsWith ( 'test-fixtures/record-upload.config.ts' ) ) return true
if ( path . endsWith ( '/code-cut/data.json' ) ) return true
if ( path . endsWith ( '/styled-backgrounds/data.json' ) ) return true
if ( path . endsWith ( '/styled-backgrounds/recording.mp4' ) ) return true
return false
} )
mockSpawn . mockImplementation ( ( _command : string , args : string [ ] ) = > {
const child = Object . assign ( new EventEmitter ( ) , {
unref : vi.fn ( ) ,
stdout : new EventEmitter ( ) ,
stderr : new EventEmitter ( ) ,
} ) as unknown as ChildProcess & {
stdout : EventEmitter
stderr : EventEmitter
}
process . nextTick ( ( ) = > {
if ( args . includes ( '--list' ) ) {
child . stdout . emit (
'data' ,
JSON . stringify ( {
suites : [ { specs : [ { title : 'Code Cut' } ] } ] ,
} )
)
}
child . emit ( 'close' , 0 )
} )
return child
} )
const { main } = await import ( './cli' )
await expect ( main ( ) ) . rejects . toThrow (
'Not all recordings succeeded to upload.'
)
expect ( mockFetch ) . not . toHaveBeenCalledWith (
expect . stringContaining ( '/cli/upload/start' ) ,
expect . objectContaining ( {
body : expect.stringContaining ( 'Styled Backgrounds' ) ,
} )
)
expect (
mockWriteFile . mock . calls . some (
( [ path ] ) = >
typeof path === 'string' && path . endsWith ( 'last-record.json' )
)
) . toBe ( false )
expect (
loggerInfoSpy . mock . calls . some ( ( call ) = >
String ( call [ 0 ] ) . includes ( 'Results available at:' )
)
) . toBe ( false )
expect ( loggerWarnSpy ) . toHaveBeenCalledWith (
'Code Cut: Missing recording.mp4 for "Code Cut"'
)
} )
2026-06-11 16:56:28 +03:00
it ( 'should only log the config path in verbose mode' , async ( ) = > {
2026-08-12 19:59:23 +03:00
process . argv = [ 'node' , 'cli.js' , 'export' , '--verbose' ]
2026-06-11 16:56:28 +03:00
process . env . VITE_APP_BASE_URL = 'https://example.com'
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
if ( String ( path ) . endsWith ( 'screenci.config.ts' ) ) {
return ` export default defineConfig({ projectName: 'Test Project' }) `
}
if ( String ( path ) . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
return ''
} )
mockFetch . mockResolvedValue ( {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { projectId : 'project_123' } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
} )
mockSpawn . mockImplementation ( ( ) = > {
process . nextTick ( ( ) = > mockChildProcess . emit ( 'close' , 0 ) )
return mockChildProcess as unknown as ChildProcess
} )
const { main } = await import ( './cli' )
await main ( )
expect ( loggerInfoSpy ) . toHaveBeenCalledWith (
expect . stringContaining ( 'Using config:' )
)
} )
it ( 'uploads completed recordings normally' , async ( ) = > {
mockReaddir . mockResolvedValue ( [ 'demo-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'record-upload.config.ts' ) ) {
return "export default { projectName: 'Test Project' }"
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( { events : [ ] , metadata : { videoName : 'Demo' } } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'test-fixtures/record-upload.config.ts' ) ||
path . endsWith ( 'data.json' ) ||
path . endsWith ( 'recording.mp4' )
)
mockFetch . mockImplementation ( async ( input : string | URL ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_123' ,
projectId : 'project_123' ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if ( url . endsWith ( '/cli/upload/recording_123/recording' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
} )
mockSpawn . mockImplementation ( ( ) = > {
process . nextTick ( ( ) = > mockChildProcess . emit ( 'close' , 0 ) )
return mockChildProcess as unknown as ChildProcess
} )
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-06-11 16:56:28 +03:00
const result = await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-06-11 16:56:28 +03:00
)
expect ( result ) . toEqual ( {
projectId : 'project_123' ,
2026-06-12 20:11:08 +03:00
recordId : expect.any ( String ) ,
2026-06-11 16:56:28 +03:00
hadFailures : false ,
2026-07-06 00:54:35 +03:00
uploadedVideoNames : expect.any ( Array ) ,
2026-06-12 21:59:19 +03:00
studioNotices : [ ] ,
2026-07-01 17:43:51 +03:00
elevenLabsKeyMissingVideos : [ ] ,
2026-07-01 21:02:14 +03:00
notices : [ ] ,
2026-06-11 16:56:28 +03:00
failedVideoNames : [ ] ,
failedVideoMessages : [ ] ,
2026-06-15 01:31:02 +03:00
plan : null ,
2026-06-11 16:56:28 +03:00
} )
expect ( mockReaddir ) . toHaveBeenCalledWith ( '/repo/.screenci' )
expect ( mockReadFile ) . toHaveBeenCalledWith (
expect . stringContaining ( '/repo/.screenci/demo-video/data.json' ) ,
'utf-8'
)
} )
2026-07-02 08:56:28 +03:00
it ( 'hard-fails a video whose upload is rejected for a missing ElevenLabs key' , async ( ) = > {
2026-07-01 17:43:51 +03:00
mockReaddir . mockResolvedValue ( [ 'demo-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'record-upload.config.ts' ) ) {
return "export default { projectName: 'Test Project' }"
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( { events : [ ] , metadata : { videoName : 'Demo' } } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'test-fixtures/record-upload.config.ts' ) ||
path . endsWith ( 'data.json' ) ||
path . endsWith ( 'recording.mp4' )
)
2026-07-02 08:56:28 +03:00
// The backend fails the render immediately and replies with an error so
// the CLI hard-fails at record time instead of surfacing a soft warning.
const errorBody = JSON . stringify ( {
error :
'No ElevenLabs API key is available. Add one on the Secrets page (https://app.screenci.com/secrets).' ,
elevenLabsKeyMissing : true ,
} )
2026-07-01 17:43:51 +03:00
mockFetch . mockImplementation ( async ( input : string | URL ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
return {
2026-07-02 08:56:28 +03:00
ok : false ,
status : 422 ,
json : vi.fn ( ) . mockResolvedValue ( JSON . parse ( errorBody ) ) ,
text : vi.fn ( ) . mockResolvedValue ( errorBody ) ,
2026-07-01 17:43:51 +03:00
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
} )
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-07-01 17:43:51 +03:00
const result = await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-07-01 17:43:51 +03:00
)
expect ( result . elevenLabsKeyMissingVideos ) . toEqual ( [ 'Demo' ] )
2026-07-02 08:56:28 +03:00
expect ( result . hadFailures ) . toBe ( true )
expect ( result . failedVideoNames ) . toContain ( 'Demo' )
// The dedicated missing-key error is surfaced once (via the summary), not
// duplicated as a generic upload-failure message.
expect ( result . failedVideoMessages ) . toEqual ( [ ] )
2026-07-01 17:43:51 +03:00
} )
2026-07-01 21:02:14 +03:00
it ( 'surfaces informational notices from the upload response' , async ( ) = > {
mockReaddir . mockResolvedValue ( [ 'demo-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'record-upload.config.ts' ) ) {
return "export default { projectName: 'Test Project' }"
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( { events : [ ] , metadata : { videoName : 'Demo' } } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'test-fixtures/record-upload.config.ts' ) ||
path . endsWith ( 'data.json' ) ||
path . endsWith ( 'recording.mp4' )
)
mockFetch . mockImplementation ( async ( input : string | URL ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_123' ,
projectId : 'project_123' ,
notices : [ 'Heads up: rendering may take a little longer today.' ] ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
} )
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-07-01 21:02:14 +03:00
const result = await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-07-01 21:02:14 +03:00
)
expect ( result . notices ) . toEqual ( [
'Heads up: rendering may take a little longer today.' ,
] )
} )
2026-07-05 21:36:03 +03:00
it ( 'prints the result URL without an upgrade mention for free plans' , async ( ) = > {
2026-06-14 19:50:30 +03:00
process . argv = [
'node' ,
'cli.js' ,
2026-08-12 19:59:23 +03:00
'export' ,
2026-06-14 19:50:30 +03:00
'--config' ,
'test-fixtures/record-upload.config.ts' ,
]
mockReaddir . mockResolvedValue ( [ 'demo-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'record-upload.config.ts' ) ) {
return "export default { projectName: 'Test Project' }"
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( { events : [ ] , metadata : { videoName : 'Demo' } } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'test-fixtures/record-upload.config.ts' ) ||
path . endsWith ( 'data.json' ) ||
path . endsWith ( 'recording.mp4' )
)
mockFetch . mockImplementation ( async ( input : string | URL ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_123' ,
projectId : 'project_123' ,
2026-07-05 21:36:03 +03:00
plan : 'free' ,
2026-06-14 19:50:30 +03:00
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
2026-08-12 19:59:23 +03:00
if ( url . includes ( '/cli/info' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
projectName : 'Test Project' ,
projectId : 'project_123' ,
videos : {
Demo : {
videoId : 'video_123' ,
languages : {
en : {
latestRecord : {
status : 'finished' ,
download : {
video :
'http://localhost:8787/cli/download/video_123/records/r1/en/video' ,
screenshot :
'http://localhost:8787/cli/download/video_123/records/r1/en/screenshot' ,
} ,
} ,
} ,
} ,
} ,
} ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if ( url . includes ( '/cli/download/' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
arrayBuffer : vi.fn ( ) . mockResolvedValue ( new ArrayBuffer ( 4 ) ) ,
}
}
2026-06-14 19:50:30 +03:00
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
} )
mockSpawn . mockImplementation ( ( ) = > {
process . nextTick ( ( ) = > mockChildProcess . emit ( 'close' , 0 ) )
return mockChildProcess as unknown as ChildProcess
} )
const { main } = await import ( './cli' )
await main ( )
const messages = loggerInfoSpy . mock . calls . map ( ( call ) = >
stripVTControlCharacters ( String ( call [ 0 ] ) )
)
expect ( messages ) . toContain (
2026-08-12 19:59:23 +03:00
'Recording finished, export render in progress. Results available at:'
2026-06-14 19:50:30 +03:00
)
2026-07-05 21:36:03 +03:00
expect (
messages . some ( ( message ) = > message . includes ( 'ScreenCI watermark' ) )
) . toBe ( false )
2026-06-14 19:50:30 +03:00
expect ( messages . some ( ( message ) = > message . includes ( '/select-plan' ) ) ) . toBe (
2026-07-05 21:36:03 +03:00
false
2026-06-14 19:50:30 +03:00
)
} )
2026-08-13 12:18:18 +03:00
it ( 'points an anonymous export at edit and sign-up without touching the trial session' , async ( ) = > {
2026-07-05 17:54:57 +03:00
delete process . env . SCREENCI_SECRET
process . env . SCREENCI_ENVIRONMENT = 'local'
process . argv = [
'node' ,
'cli.js' ,
2026-08-12 19:59:23 +03:00
'export' ,
2026-07-05 17:54:57 +03:00
'--config' ,
'test-fixtures/record-upload.config.ts' ,
]
mockReaddir . mockResolvedValue ( [ 'demo-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'record-upload.config.ts' ) ) {
return "export default { projectName: 'Test Project' }"
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( { events : [ ] , metadata : { videoName : 'Demo' } } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'test-fixtures/record-upload.config.ts' ) ||
path . endsWith ( 'data.json' ) ||
path . endsWith ( 'recording.mp4' )
)
mockFetch . mockImplementation ( async ( input : string | URL ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_123' ,
projectId : 'project_123' ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
} )
mockSpawn . mockImplementation ( ( ) = > {
process . nextTick ( ( ) = > mockChildProcess . emit ( 'close' , 0 ) )
return mockChildProcess as unknown as ChildProcess
} )
const { main } = await import ( './cli' )
2026-08-13 12:18:18 +03:00
await expect ( main ( ) ) . rejects . toThrow ( 'process.exit called' )
2026-07-05 17:54:57 +03:00
2026-08-13 12:18:18 +03:00
expect ( processExitSpy ) . toHaveBeenCalledWith ( 1 )
// Nothing was recorded or uploaded: only the test-discovery pass ran.
expect (
mockFetch . mock . calls . some ( ( call ) = >
String ( call [ 0 ] ) . includes ( '/cli/upload/start' )
)
) . toBe ( false )
const errors = loggerErrorSpy . mock . calls . map ( ( call ) = >
2026-07-05 23:13:45 +03:00
stripVTControlCharacters ( String ( call [ 0 ] ) )
)
2026-08-13 12:18:18 +03:00
const refusal = errors . find ( ( message ) = >
message . includes ( 'Exporting requires an account' )
2026-07-05 23:13:45 +03:00
)
2026-08-13 12:18:18 +03:00
expect ( refusal ) . toBeDefined ( )
expect ( refusal ) . toContain ( 'edit' )
expect ( refusal ) . toContain ( 'sign up to export' )
// The trial session file is left alone: previews keep working.
const anonSessionDeletes = mockWriteFile . mock . calls . filter ( ( call ) = >
2026-07-05 17:54:57 +03:00
String ( call [ 0 ] ) . endsWith ( 'anon-session.json' )
)
expect (
2026-08-13 12:18:18 +03:00
anonSessionDeletes . every ( ( call ) = > String ( call [ 1 ] ) . includes ( 'token' ) )
2026-07-05 17:54:57 +03:00
) . toBe ( true )
} )
2026-06-15 01:31:02 +03:00
it ( 'omits the upgrade mention for business plans' , async ( ) = > {
process . argv = [
'node' ,
'cli.js' ,
2026-08-12 19:59:23 +03:00
'export' ,
2026-06-15 01:31:02 +03:00
'--config' ,
'test-fixtures/record-upload.config.ts' ,
]
mockReaddir . mockResolvedValue ( [ 'demo-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'record-upload.config.ts' ) ) {
return "export default { projectName: 'Test Project' }"
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( { events : [ ] , metadata : { videoName : 'Demo' } } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'test-fixtures/record-upload.config.ts' ) ||
path . endsWith ( 'data.json' ) ||
path . endsWith ( 'recording.mp4' )
)
mockFetch . mockImplementation ( async ( input : string | URL ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_123' ,
projectId : 'project_123' ,
plan : 'business' ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
2026-08-12 19:59:23 +03:00
if ( url . includes ( '/cli/info' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
projectName : 'Test Project' ,
projectId : 'project_123' ,
videos : {
Demo : {
videoId : 'video_123' ,
languages : {
en : {
latestRecord : {
status : 'finished' ,
download : {
video :
'http://localhost:8787/cli/download/video_123/records/r1/en/video' ,
screenshot :
'http://localhost:8787/cli/download/video_123/records/r1/en/screenshot' ,
} ,
} ,
} ,
} ,
} ,
} ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if ( url . includes ( '/cli/download/' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
arrayBuffer : vi.fn ( ) . mockResolvedValue ( new ArrayBuffer ( 4 ) ) ,
}
}
2026-06-15 01:31:02 +03:00
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
} )
mockSpawn . mockImplementation ( ( ) = > {
process . nextTick ( ( ) = > mockChildProcess . emit ( 'close' , 0 ) )
return mockChildProcess as unknown as ChildProcess
} )
const { main } = await import ( './cli' )
await main ( )
const messages = loggerInfoSpy . mock . calls . map ( ( call ) = >
stripVTControlCharacters ( String ( call [ 0 ] ) )
)
expect ( messages ) . toContain (
2026-08-12 19:59:23 +03:00
'Recording finished, export render in progress. Results available at:'
2026-06-15 01:31:02 +03:00
)
expect ( messages . some ( ( message ) = > message . includes ( '/select-plan' ) ) ) . toBe (
false
)
} )
2026-06-12 21:59:19 +03:00
it ( 'surfaces studio hold and override notices from the upload start response' , async ( ) = > {
mockReaddir . mockResolvedValue ( [ 'demo-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( { events : [ ] , metadata : { videoName : 'Demo' } } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'data.json' ) || path . endsWith ( 'recording.mp4' )
)
mockFetch . mockImplementation ( async ( input : string | URL ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_123' ,
projectId : 'project_123' ,
videoId : 'video_123' ,
studio : { held : true } ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
} )
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-06-12 21:59:19 +03:00
const result = await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-06-12 21:59:19 +03:00
)
expect ( result . studioNotices ) . toEqual ( [
{
videoName : 'Demo' ,
videoId : 'video_123' ,
studio : { held : true } ,
} ,
] )
} )
2026-06-19 09:12:49 +03:00
it ( 'surfaces an applied studio notice when configuration was applied' , async ( ) = > {
mockReaddir . mockResolvedValue ( [ 'demo-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( { events : [ ] , metadata : { videoName : 'Demo' } } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'data.json' ) || path . endsWith ( 'recording.mp4' )
)
mockFetch . mockImplementation ( async ( input : string | URL ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_123' ,
projectId : 'project_123' ,
videoId : 'video_123' ,
studio : { applied : true } ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
2026-06-12 21:59:19 +03:00
2026-06-19 09:12:49 +03:00
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
} )
2026-06-18 13:52:20 +03:00
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-06-19 09:12:49 +03:00
const result = await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-06-19 09:12:49 +03:00
)
expect ( result . studioNotices ) . toEqual ( [
{
videoName : 'Demo' ,
videoId : 'video_123' ,
studio : { applied : true } ,
} ,
] )
} )
2026-06-18 13:52:20 +03:00
2026-06-19 09:12:49 +03:00
it ( 'formats studio URLs' , async ( ) = > {
const { formatStudioUrl } = await import ( './cli' )
2026-06-12 21:59:19 +03:00
expect (
formatStudioUrl ( 'https://app.screenci.test' , 'project_1' , 'video_2' )
2026-07-05 21:36:03 +03:00
) . toBe ( 'https://app.screenci.test/project/project_1/video/video_2?editor' )
2026-06-12 21:59:19 +03:00
} )
2026-07-01 17:43:51 +03:00
it ( 'never forwards an ElevenLabs key: the key lives only in the app now' , async ( ) = > {
// Even if a legacy ELEVENLABS_API_KEY is present in the environment, the
// CLI must not send it: the key is stored (encrypted) in the app instead.
2026-06-12 20:11:08 +03:00
process . env . ELEVENLABS_API_KEY = 'elevenlabs-byok-key'
mockReaddir . mockResolvedValue ( [ 'demo-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'record-upload.config.ts' ) ) {
return "export default { projectName: 'Test Project' }"
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( { events : [ ] , metadata : { videoName : 'Demo' } } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'test-fixtures/record-upload.config.ts' ) ||
path . endsWith ( 'data.json' ) ||
path . endsWith ( 'recording.mp4' )
)
mockFetch . mockImplementation ( async ( input : string | URL ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_123' ,
projectId : 'project_123' ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if ( url . endsWith ( '/cli/upload/recording_123/recording' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
} )
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-06-12 20:11:08 +03:00
await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-06-12 20:11:08 +03:00
)
2026-07-01 17:43:51 +03:00
const startCall = mockFetch . mock . calls . find (
( [ url ] ) = > String ( url ) === 'https://api.screenci.test/cli/upload/start'
2026-06-12 20:11:08 +03:00
)
2026-07-01 17:43:51 +03:00
const recordingCall = mockFetch . mock . calls . find (
( [ url ] ) = >
String ( url ) ===
'https://api.screenci.test/cli/upload/recording_123/recording'
)
expect ( startCall ? . [ 1 ] . headers ) . not . toHaveProperty ( 'X-ElevenLabs-Api-Key' )
expect ( recordingCall ? . [ 1 ] . headers ) . not . toHaveProperty (
'X-ElevenLabs-Api-Key'
2026-06-12 20:11:08 +03:00
)
2026-07-01 17:43:51 +03:00
expect ( startCall ? . [ 1 ] . headers ) . toMatchObject ( {
'X-ScreenCI-Secret' : 'test-secret' ,
} )
2026-06-12 20:11:08 +03:00
} )
2026-06-16 16:50:39 +03:00
it ( 'does not forward arbitrary env vars (e.g. user app secrets) to the service' , async ( ) = > {
process . env . YOUR_PRIVATE_SECRET = 'super-secret-app-key'
process . env . GOOGLE_CLOUD_API_KEY = 'should-never-leave-the-machine'
delete process . env . ELEVENLABS_API_KEY
mockReaddir . mockResolvedValue ( [ 'demo-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'record-upload.config.ts' ) ) {
return "export default { projectName: 'Test Project' }"
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( { events : [ ] , metadata : { videoName : 'Demo' } } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'test-fixtures/record-upload.config.ts' ) ||
path . endsWith ( 'data.json' ) ||
path . endsWith ( 'recording.mp4' )
)
mockFetch . mockImplementation ( async ( input : string | URL ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_123' ,
projectId : 'project_123' ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
} )
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-06-16 16:50:39 +03:00
await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-06-16 16:50:39 +03:00
)
const sentHeaders = mockFetch . mock . calls . flatMap ( ( call ) = > {
const init = call [ 1 ] as { headers? : Record < string , string > } | undefined
return init ? . headers ? [ init . headers ] : [ ]
} )
expect ( sentHeaders . length ) . toBeGreaterThan ( 0 )
for ( const headers of sentHeaders ) {
const allowedHeaderNames = new Set ( [
'Content-Type' ,
'Content-Length' ,
'X-ScreenCI-Secret' ,
] )
for ( const name of Object . keys ( headers ) ) {
expect ( allowedHeaderNames . has ( name ) ) . toBe ( true )
}
const serialized = JSON . stringify ( headers )
expect ( serialized ) . not . toContain ( 'super-secret-app-key' )
expect ( serialized ) . not . toContain ( 'should-never-leave-the-machine' )
expect ( serialized . toUpperCase ( ) ) . not . toContain ( 'GOOGLE' )
expect ( serialized . toUpperCase ( ) ) . not . toContain ( 'VERTEX' )
}
} )
2026-06-11 16:56:28 +03:00
it ( 'uploads completed recordings after partial failure with default policy, then still fails' , async ( ) = > {
process . argv = [
'node' ,
'cli.js' ,
2026-08-12 19:59:23 +03:00
'export' ,
2026-06-11 16:56:28 +03:00
'--config' ,
'test-fixtures/record-upload.config.ts' ,
]
mockReaddir . mockResolvedValue ( [ 'demo-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'record-upload.config.ts' ) ) {
return "export default { projectName: 'Test Project' }"
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( { events : [ ] , metadata : { videoName : 'Demo' } } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'test-fixtures/record-upload.config.ts' ) ||
path . endsWith ( 'data.json' ) ||
path . endsWith ( 'recording.mp4' )
)
mockFetch . mockImplementation ( async ( input : string | URL ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_123' ,
projectId : 'project_123' ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if ( url . endsWith ( '/cli/upload/recording_123/recording' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
} )
mockSpawn . mockImplementation ( ( ) = > {
process . nextTick ( ( ) = > mockChildProcess . emit ( 'close' , 1 ) )
return mockChildProcess as unknown as ChildProcess
} )
const { main } = await import ( './cli' )
await expect ( main ( ) ) . rejects . toThrow ( 'Playwright exited with code 1' )
expect ( mockReaddir ) . toHaveBeenCalledWith (
expect . stringContaining ( '.screenci' )
)
expect ( loggerWarnSpy ) . toHaveBeenCalledWith (
'Some recordings failed, uploading successful videos only.'
)
2026-07-11 21:52:29 +03:00
// Cleanup removes the uploaded media but keeps data.json for the next
// dev session's freshness check.
expect ( mockReaddirSync ) . toHaveBeenCalledWith (
expect . stringContaining ( '/.screenci/demo-video' )
2026-06-11 16:56:28 +03:00
)
2026-07-11 21:52:29 +03:00
const removed = mockRmSync . mock . calls . map ( ( call ) = > String ( call [ 0 ] ) )
expect (
removed . some ( ( path ) = > path . endsWith ( 'demo-video/data.json' ) )
) . toBe ( false )
2026-06-11 16:56:28 +03:00
} )
it ( 'skips upload after partial failure with all-or-nothing policy, then still fails' , async ( ) = > {
process . argv = [
'node' ,
'cli.js' ,
2026-08-12 19:59:23 +03:00
'export' ,
2026-06-11 16:56:28 +03:00
'--config' ,
'test-fixtures/record-upload-all-or-nothing.config.ts' ,
]
mockReaddir . mockResolvedValue ( [ 'demo-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( { events : [ ] , metadata : { videoName : 'Demo' } } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith (
'test-fixtures/record-upload-all-or-nothing.config.ts'
) || path . endsWith ( 'data.json' )
)
mockSpawn . mockImplementation ( ( ) = > {
process . nextTick ( ( ) = > mockChildProcess . emit ( 'close' , 1 ) )
return mockChildProcess as unknown as ChildProcess
} )
const { main } = await import ( './cli' )
await expect ( main ( ) ) . rejects . toThrow ( 'Playwright exited with code 1' )
expect ( mockFetch ) . not . toHaveBeenCalledWith (
expect . stringContaining ( '/cli/upload/start' ) ,
expect . any ( Object )
)
expect ( loggerInfoSpy ) . toHaveBeenCalledWith (
'Some recordings failed, skipping upload because record.upload is "all-or-nothing".'
)
} )
it ( 'skips entries without data.json in passed-only upload flow' , async ( ) = > {
mockReaddir . mockResolvedValue ( [ 'failed-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
return ''
} )
mockExistsSync . mockImplementation ( ( path : string ) = >
path . endsWith ( 'recording.mp4' )
)
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-06-11 16:56:28 +03:00
const result = await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-06-11 16:56:28 +03:00
)
expect ( result ) . toEqual ( {
projectId : null ,
2026-06-12 20:11:08 +03:00
recordId : null ,
2026-06-11 16:56:28 +03:00
hadFailures : false ,
2026-07-06 00:54:35 +03:00
uploadedVideoNames : expect.any ( Array ) ,
2026-06-12 21:59:19 +03:00
studioNotices : [ ] ,
2026-07-01 17:43:51 +03:00
elevenLabsKeyMissingVideos : [ ] ,
2026-07-01 21:02:14 +03:00
notices : [ ] ,
2026-06-11 16:56:28 +03:00
failedVideoNames : [ ] ,
failedVideoMessages : [ ] ,
2026-06-15 01:31:02 +03:00
plan : null ,
2026-06-11 16:56:28 +03:00
} )
expect ( mockFetch ) . not . toHaveBeenCalled ( )
} )
it ( 'fails an upload candidate when recording.mp4 is missing' , async ( ) = > {
mockReaddir . mockResolvedValue ( [ 'demo-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( { events : [ ] , metadata : { videoName : 'Demo' } } )
}
return ''
} )
mockExistsSync . mockImplementation ( ( path : string ) = >
path . endsWith ( 'data.json' )
)
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-06-11 16:56:28 +03:00
const result = await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-06-11 16:56:28 +03:00
)
expect ( result ) . toEqual ( {
projectId : null ,
2026-06-12 20:11:08 +03:00
recordId : expect.any ( String ) ,
2026-06-11 16:56:28 +03:00
hadFailures : true ,
2026-07-06 00:54:35 +03:00
uploadedVideoNames : expect.any ( Array ) ,
2026-06-12 21:59:19 +03:00
studioNotices : [ ] ,
2026-07-01 17:43:51 +03:00
elevenLabsKeyMissingVideos : [ ] ,
2026-07-01 21:02:14 +03:00
notices : [ ] ,
2026-06-11 16:56:28 +03:00
failedVideoNames : [ 'Demo' ] ,
failedVideoMessages : [
{
videoName : 'Demo' ,
message : 'Missing recording.mp4 for "Demo"' ,
} ,
] ,
2026-06-15 01:31:02 +03:00
plan : null ,
2026-06-11 16:56:28 +03:00
} )
expect ( mockFetch ) . not . toHaveBeenCalledWith (
expect . stringContaining ( '/cli/upload/start' ) ,
expect . anything ( )
)
} )
2026-06-21 19:26:16 +03:00
it ( 'uploads a screenshot recording as image/png from screenshot.png' , async ( ) = > {
mockReaddir . mockResolvedValue ( [ 'home' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( {
events : [ ] ,
output : 'screenshot' ,
screenshot : {
path : 'screenshot.png' ,
width : 1920 ,
height : 1080 ,
deviceScaleFactor : 1 ,
} ,
metadata : { videoName : 'home' } ,
} )
}
return ''
} )
// The screenshot capture exists, but there is no recording.mp4.
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'data.json' ) || path . endsWith ( 'screenshot.png' )
)
let recordingPut : RequestInit | undefined
2026-06-26 22:10:52 +03:00
let startBody : Record < string , unknown > | undefined
2026-06-21 19:26:16 +03:00
mockFetch . mockImplementation (
async ( input : string | URL , init? : RequestInit ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
2026-06-26 22:10:52 +03:00
startBody = JSON . parse ( String ( init ? . body ) )
2026-06-21 19:26:16 +03:00
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_123' ,
projectId : 'project_123' ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if ( url . endsWith ( '/cli/upload/recording_123/recording' ) ) {
recordingPut = init
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
)
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-06-21 19:26:16 +03:00
const result = await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-06-21 19:26:16 +03:00
)
expect ( result ) . toEqual ( {
projectId : 'project_123' ,
recordId : expect.any ( String ) ,
hadFailures : false ,
2026-07-06 00:54:35 +03:00
uploadedVideoNames : expect.any ( Array ) ,
2026-06-21 19:26:16 +03:00
studioNotices : [ ] ,
2026-07-01 17:43:51 +03:00
elevenLabsKeyMissingVideos : [ ] ,
2026-07-01 21:02:14 +03:00
notices : [ ] ,
2026-06-21 19:26:16 +03:00
failedVideoNames : [ ] ,
failedVideoMessages : [ ] ,
plan : null ,
} )
// The capture is streamed from screenshot.png with an image content type.
expect ( mockCreateReadStream ) . toHaveBeenCalledWith (
expect . stringContaining ( 'screenshot.png' )
)
expect (
( recordingPut ? . headers as Record < string , string > | undefined ) ? . [
'Content-Type'
]
) . toBe ( 'image/png' )
2026-06-26 22:10:52 +03:00
// The screenshot upload declares how many screenshots this run produced so
// the backend can batch them onto one machine.
expect ( startBody ? . expectedScreenshotCount ) . toBe ( 1 )
2026-06-21 19:26:16 +03:00
} )
2026-06-11 16:56:28 +03:00
it ( 'fails the upload when an asset check fails' , async ( ) = > {
mockReaddir . mockResolvedValue ( [ 'demo-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( {
events : [
{
type : 'assetStart' ,
timeMs : 0 ,
name : 'logo' ,
kind : 'image' ,
path : 'videos/logo.png' ,
durationMs : 1200 ,
fullScreen : false ,
} ,
] ,
metadata : { videoName : 'Demo' } ,
} )
}
if ( pathString . endsWith ( 'videos/logo.png' ) ) {
return Buffer . from ( 'logo-bytes' )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'data.json' ) ||
path . endsWith ( 'recording.mp4' ) ||
path . endsWith ( 'videos/logo.png' )
)
mockFetch . mockImplementation ( async ( input : string | URL ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_123' ,
projectId : 'project_123' ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if ( url . endsWith ( '/asset/check' ) ) {
return {
ok : false ,
status : 500 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( 'backend exploded' ) ,
}
}
if ( url . endsWith ( '/cli/upload/recording_123/recording' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
} )
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-06-11 16:56:28 +03:00
const result = await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-06-11 16:56:28 +03:00
)
expect ( result ) . toEqual ( {
projectId : 'project_123' ,
2026-06-12 20:11:08 +03:00
recordId : expect.any ( String ) ,
2026-06-11 16:56:28 +03:00
hadFailures : true ,
2026-07-06 00:54:35 +03:00
uploadedVideoNames : expect.any ( Array ) ,
2026-06-12 21:59:19 +03:00
studioNotices : [ ] ,
2026-07-01 17:43:51 +03:00
elevenLabsKeyMissingVideos : [ ] ,
2026-07-01 21:02:14 +03:00
notices : [ ] ,
2026-06-11 16:56:28 +03:00
failedVideoNames : [ 'Demo' ] ,
failedVideoMessages : [
{
videoName : 'Demo' ,
message :
'Failed to check asset videos/logo.png: 500 backend exploded' ,
} ,
] ,
2026-06-15 01:31:02 +03:00
plan : null ,
2026-06-11 16:56:28 +03:00
} )
expect (
mockFetch . mock . calls . some ( ( [ input ] ) = >
String ( input ) . endsWith ( '/cli/upload/recording_123/recording' )
)
) . toBe ( false )
} )
2026-06-27 03:01:18 +03:00
it ( 'reports absolute asset paths relative to the cwd in failure messages' , async ( ) = > {
const cwd = '/home/runner/work/repo/repo/apps/demo'
const cwdSpy = vi . spyOn ( process , 'cwd' ) . mockReturnValue ( cwd )
const absoluteAssetPath = ` ${ cwd } /.screenci/Demo/generated/ring.png `
mockReaddir . mockResolvedValue ( [ 'demo-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( {
events : [
{
type : 'assetStart' ,
timeMs : 0 ,
name : 'ring' ,
kind : 'image' ,
path : absoluteAssetPath ,
durationMs : 1200 ,
fullScreen : false ,
} ,
] ,
metadata : { videoName : 'Demo' } ,
} )
}
if ( pathString . endsWith ( 'ring.png' ) ) {
return Buffer . from ( 'ring-bytes' )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'data.json' ) ||
path . endsWith ( 'recording.mp4' ) ||
path . endsWith ( 'ring.png' )
)
mockFetch . mockImplementation ( async ( input : string | URL ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_123' ,
projectId : 'project_123' ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if ( url . endsWith ( '/asset/stream' ) ) {
return {
ok : false ,
status : 500 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '{"error":"Upload failed"}' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
} )
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-06-27 03:01:18 +03:00
const result = await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-06-27 03:01:18 +03:00
)
// The failure message uses the cwd-relative path, not the absolute one.
expect ( result . failedVideoMessages ) . toEqual ( [
{
videoName : 'Demo' ,
message :
2026-07-01 21:02:14 +03:00
'Failed to upload asset .screenci/Demo/generated/ring.png: 500 Upload failed' ,
2026-06-27 03:01:18 +03:00
} ,
] )
cwdSpy . mockRestore ( )
} )
2026-06-11 16:56:28 +03:00
it ( 'resolves asset paths relative to the recording source file during upload' , async ( ) = > {
mockReaddir . mockResolvedValue ( [ 'demo-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( {
events : [
{
type : 'assetStart' ,
timeMs : 0 ,
name : 'nested-clip' ,
kind : 'video' ,
path : './asset.mp4' ,
audio : 0 ,
fullScreen : true ,
} ,
] ,
metadata : {
videoName : 'Demo' ,
feat: add screenshot() fixture, crop(), and screenshot render options
Authoring API for still screenshots alongside videos:
- screenshot() fixture captures the page via page.screenshot (no mp4)
- crop(target, { padding }) to a Playwright locator or fractional region
- recordOptions.deviceScaleFactor for higher-DPI capture
- forward use context options (colorScheme, locale, storageState, ...) into
the screenci-created context, fixing colorScheme: 'dark' for video too
- renderOptions: recording.margin, output.aspectRatio 'auto', output.format
('png' | { type: 'jpeg', quality })
- RecordingData gains an output discriminator + screenshot capture info
Discovery moves to *.screenci.ts (video and screenshot fixtures share one
file); *.video.ts kept as an undocumented deprecated alias. Docs + tests.
2026-06-20 23:57:10 +03:00
sourceFilePath : 'videos/nested/demo.screenci.ts' ,
2026-06-11 16:56:28 +03:00
} ,
} )
}
if ( pathString . endsWith ( 'videos/nested/asset.mp4' ) ) {
return Buffer . from ( 'nested-asset' )
}
throw new Error ( ` ENOENT: ${ pathString } ` )
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'data.json' ) || path . endsWith ( 'recording.mp4' )
)
let startBody :
| {
expectedAssets? : Array < {
path : string
size : number
fileHash : string
} >
}
| undefined
mockFetch . mockImplementation (
async ( input : string | URL , init? : RequestInit ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
startBody = JSON . parse (
String ( init ? . body ? ? '{}' )
) as typeof startBody
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_123' ,
projectId : 'project_123' ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if ( url . endsWith ( '/asset/check' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { exists : true } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if ( url . endsWith ( '/cli/upload/recording_123/recording' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
)
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-06-11 16:56:28 +03:00
const result = await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-06-11 16:56:28 +03:00
)
expect ( result ) . toEqual ( {
projectId : 'project_123' ,
2026-06-12 20:11:08 +03:00
recordId : expect.any ( String ) ,
2026-06-11 16:56:28 +03:00
hadFailures : false ,
2026-07-06 00:54:35 +03:00
uploadedVideoNames : expect.any ( Array ) ,
2026-06-12 21:59:19 +03:00
studioNotices : [ ] ,
2026-07-01 17:43:51 +03:00
elevenLabsKeyMissingVideos : [ ] ,
2026-07-01 21:02:14 +03:00
notices : [ ] ,
2026-06-11 16:56:28 +03:00
failedVideoNames : [ ] ,
failedVideoMessages : [ ] ,
2026-06-15 01:31:02 +03:00
plan : null ,
2026-06-11 16:56:28 +03:00
} )
expect ( startBody ? . expectedAssets ) . toEqual ( [
expect . objectContaining ( {
fileHash : expect.any ( String ) ,
path : './asset.mp4' ,
size : Buffer.from ( 'nested-asset' ) . byteLength ,
} ) ,
] )
} )
2026-06-25 01:11:01 +03:00
it ( 'streams raw asset bytes (not base64) to /asset/stream with metadata headers' , async ( ) = > {
mockReaddir . mockResolvedValue ( [ 'demo-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( {
events : [
{
type : 'assetStart' ,
timeMs : 0 ,
name : 'nested-clip' ,
kind : 'video' ,
path : './asset.mp4' ,
audio : 0 ,
fullScreen : true ,
} ,
] ,
metadata : {
videoName : 'Demo' ,
sourceFilePath : 'videos/nested/demo.screenci.ts' ,
} ,
} )
}
if ( pathString . endsWith ( 'videos/nested/asset.mp4' ) ) {
return Buffer . from ( 'nested-asset' )
}
throw new Error ( ` ENOENT: ${ pathString } ` )
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'data.json' ) || path . endsWith ( 'recording.mp4' )
)
let assetPut : { url : string ; init : RequestInit } | undefined
mockFetch . mockImplementation (
async ( input : string | URL , init? : RequestInit ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_123' ,
projectId : 'project_123' ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if ( url . endsWith ( '/asset/check' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { exists : false } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if ( url . endsWith ( '/asset/stream' ) ) {
assetPut = { url , init : init ! }
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { storageKey : 'assets/x.mp4' } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
)
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-06-25 01:11:01 +03:00
await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-06-25 01:11:01 +03:00
)
expect ( assetPut ) . toBeDefined ( )
expect ( assetPut ! . url ) . toBe (
'https://api.screenci.test/cli/upload/recording_123/asset/stream'
)
expect ( assetPut ! . init . method ) . toBe ( 'PUT' )
const headers = assetPut ! . init . headers as Record < string , string >
expect ( headers [ 'Content-Type' ] ) . toBe ( 'video/mp4' )
expect ( headers [ 'X-ScreenCI-File-Hash' ] ) . toBe (
createHash ( 'sha256' ) . update ( Buffer . from ( 'nested-asset' ) ) . digest ( 'hex' )
)
expect ( headers [ 'X-ScreenCI-Asset-Path' ] ) . toBe (
encodeURIComponent ( './asset.mp4' )
)
// The body is the raw buffer, not a base64 JSON string. This is the whole
// point: base64 would overflow Node's max string length on large assets.
const body = assetPut ! . init . body
expect ( typeof body ) . not . toBe ( 'string' )
expect ( Buffer . from ( body as Buffer ) . toString ( ) ) . toBe ( 'nested-asset' )
// No request anywhere carried a base64 payload.
const sentBase64 = mockFetch . mock . calls . some ( ( [ , init ] ) = > {
const b = ( init as RequestInit | undefined ) ? . body
return typeof b === 'string' && b . includes ( 'fileBase64' )
} )
expect ( sentBase64 ) . toBe ( false )
} )
2026-06-11 16:56:28 +03:00
it ( 'returns failure state when some recordings do not upload' , async ( ) = > {
mockReaddir . mockResolvedValue ( [ 'demo-video' , 'failed-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'data.json' ) ) {
const videoName = pathString . includes ( '/failed-video/' )
? 'Failed Demo'
: 'Demo'
return JSON . stringify ( { events : [ ] , metadata : { videoName } } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'data.json' ) || path . endsWith ( 'recording.mp4' )
)
mockFetch . mockImplementation ( async ( input : string | URL ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
const body = JSON . parse (
String (
( mockFetch . mock . calls . at ( - 1 ) ? . [ 1 ] as { body? : string } ) ? . body ? ?
'{}'
)
) as { videoName? : string }
if ( body . videoName === 'Failed Demo' ) {
return {
ok : false ,
status : 402 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi
. fn ( )
. mockResolvedValue ( 'Upload limit reached for current plan.' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_123' ,
projectId : 'project_123' ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if ( url . endsWith ( '/cli/upload/recording_123/recording' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
} )
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-06-11 16:56:28 +03:00
const result = await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-06-11 16:56:28 +03:00
)
expect ( result ) . toEqual ( {
projectId : 'project_123' ,
2026-06-12 20:11:08 +03:00
recordId : expect.any ( String ) ,
2026-06-11 16:56:28 +03:00
hadFailures : true ,
2026-07-06 00:54:35 +03:00
uploadedVideoNames : expect.any ( Array ) ,
2026-06-12 21:59:19 +03:00
studioNotices : [ ] ,
2026-07-01 17:43:51 +03:00
elevenLabsKeyMissingVideos : [ ] ,
2026-07-01 21:02:14 +03:00
notices : [ ] ,
2026-06-11 16:56:28 +03:00
failedVideoNames : [ 'Failed Demo' ] ,
failedVideoMessages : [
{
videoName : 'Failed Demo' ,
message : 'Upload limit reached for current plan.' ,
} ,
] ,
2026-06-15 01:31:02 +03:00
plan : null ,
2026-06-11 16:56:28 +03:00
} )
} )
2026-07-06 00:54:35 +03:00
it ( 'rejects the whole anonymous upload when the per-recording trial cap is exceeded' , async ( ) = > {
mockReaddir . mockResolvedValue ( [
'demo-video' ,
'second-video' ,
'third-video' ,
'fourth-video' ,
] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'data.json' ) ) {
const videoName = pathString . includes ( '/second-video/' )
? 'Second Demo'
: pathString . includes ( '/third-video/' )
? 'Third Demo'
: pathString . includes ( '/fourth-video/' )
? 'Fourth Demo'
: 'Demo'
return JSON . stringify ( { events : [ ] , metadata : { videoName } } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'data.json' ) || path . endsWith ( 'recording.mp4' )
)
const { uploadRecordings , anonCredential } = await import ( './cli' )
const result = await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
anonCredential ( 'anon-token' )
)
expect ( result ) . toEqual ( {
projectId : null ,
recordId : null ,
hadFailures : true ,
uploadedVideoNames : expect.any ( Array ) ,
studioNotices : [ ] ,
elevenLabsKeyMissingVideos : [ ] ,
notices : [ ] ,
failedVideoNames : [ 'Demo' , 'Second Demo' , 'Third Demo' , 'Fourth Demo' ] ,
failedVideoMessages : [
{
videoName : 'Demo' ,
message :
'Anonymous trials are capped at 3 videos/screenshots per recording. Split this into smaller runs or sign up to record more in one run.' ,
} ,
{
videoName : 'Second Demo' ,
message :
'Anonymous trials are capped at 3 videos/screenshots per recording. Split this into smaller runs or sign up to record more in one run.' ,
} ,
{
videoName : 'Third Demo' ,
message :
'Anonymous trials are capped at 3 videos/screenshots per recording. Split this into smaller runs or sign up to record more in one run.' ,
} ,
{
videoName : 'Fourth Demo' ,
message :
'Anonymous trials are capped at 3 videos/screenshots per recording. Split this into smaller runs or sign up to record more in one run.' ,
} ,
] ,
plan : null ,
} )
expect ( mockFetch ) . not . toHaveBeenCalledWith (
expect . stringContaining ( '/cli/upload/start' ) ,
expect . anything ( )
)
} )
2026-08-13 12:18:18 +03:00
it ( 'refuses an anonymous upload run over the per-recording video cap with one shared message' , async ( ) = > {
// The export command no longer reaches anonymous uploads (exports are
// account-only), but `screenci edit` preview uploads still do: the
// per-recording cap collapses into one shared warning for the run.
2026-07-06 00:54:35 +03:00
mockReaddir . mockResolvedValue ( [
'ru-video' ,
'where-video' ,
'en-video' ,
'fi-video' ,
] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'data.json' ) ) {
const metadata = pathString . includes ( '/ru-video/' )
? { videoName : 'How to find docs' , languages : [ 'ru' ] }
: pathString . includes ( '/en-video/' )
? { videoName : 'How to find docs' , languages : [ 'en' ] }
: pathString . includes ( '/fi-video/' )
? { videoName : 'How to find docs' , languages : [ 'fi' ] }
: { videoName : 'Where to find docs' }
return JSON . stringify ( { events : [ ] , metadata } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
2026-08-13 12:18:18 +03:00
path . endsWith ( 'data.json' ) || path . endsWith ( 'recording.mp4' )
2026-07-06 00:54:35 +03:00
)
2026-08-13 12:18:18 +03:00
const { uploadRecordings , collapseFailedVideoWarnings , anonCredential } =
await import ( './cli' )
2026-07-06 00:54:35 +03:00
2026-08-13 12:18:18 +03:00
const result = await uploadRecordings (
'/tmp/.screenci' ,
'Test Project' ,
'http://localhost:8787' ,
anonCredential ( 'anon-token-1' )
2026-07-06 00:54:35 +03:00
)
2026-08-13 12:18:18 +03:00
expect ( result . hadFailures ) . toBe ( true )
2026-07-06 00:54:35 +03:00
const anonCapWarning =
'Anonymous trials are capped at 3 videos/screenshots per recording. Split this into smaller runs or sign up to record more in one run.'
expect (
2026-08-13 12:18:18 +03:00
result . failedVideoMessages . every (
( failure ) = > failure . message === anonCapWarning
)
) . toBe ( true )
expect ( result . failedVideoNames ) . toEqual ( [
'How to find docs [ru]' ,
'Where to find docs' ,
'How to find docs [en]' ,
'How to find docs [fi]' ,
] )
expect ( collapseFailedVideoWarnings ( result . failedVideoMessages ) ) . toEqual ( [
anonCapWarning ,
] )
2026-07-06 00:54:35 +03:00
} )
2026-07-11 21:52:29 +03:00
it ( 'removes uploaded media but keeps data.json after successful upload' , async ( ) = > {
2026-06-11 16:56:28 +03:00
mockReaddir . mockResolvedValue ( [ 'demo-video' ] )
2026-07-11 21:52:29 +03:00
mockReaddirSync . mockReturnValue ( [
'data.json' ,
'recording.mp4' ,
] as unknown as string [ ] )
2026-06-11 16:56:28 +03:00
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( { events : [ ] , metadata : { videoName : 'Demo' } } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'data.json' ) || path . endsWith ( 'recording.mp4' )
)
mockFetch . mockImplementation ( async ( input : string | URL ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_123' ,
projectId : 'project_123' ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if ( url . endsWith ( '/cli/upload/recording_123/recording' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
} )
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-06-11 16:56:28 +03:00
const result = await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-06-11 16:56:28 +03:00
)
expect ( result ) . toEqual ( {
projectId : 'project_123' ,
2026-06-12 20:11:08 +03:00
recordId : expect.any ( String ) ,
2026-06-11 16:56:28 +03:00
hadFailures : false ,
2026-07-06 00:54:35 +03:00
uploadedVideoNames : expect.any ( Array ) ,
2026-06-12 21:59:19 +03:00
studioNotices : [ ] ,
2026-07-01 17:43:51 +03:00
elevenLabsKeyMissingVideos : [ ] ,
2026-07-01 21:02:14 +03:00
notices : [ ] ,
2026-06-11 16:56:28 +03:00
failedVideoNames : [ ] ,
failedVideoMessages : [ ] ,
2026-06-15 01:31:02 +03:00
plan : null ,
2026-06-11 16:56:28 +03:00
} )
2026-07-11 21:52:29 +03:00
// Media is removed; data.json survives so the next dev session can skip
// re-recording when the source is unchanged.
expect ( mockRmSync ) . toHaveBeenCalledWith (
'/repo/.screenci/demo-video/recording.mp4' ,
{ recursive : true , force : true }
)
const removed = mockRmSync . mock . calls . map ( ( call ) = > call [ 0 ] as string )
expect ( removed ) . not . toContain ( '/repo/.screenci/demo-video' )
expect ( removed ) . not . toContain ( '/repo/.screenci/demo-video/data.json' )
2026-06-11 16:56:28 +03:00
} )
it ( 'keeps uploaded recording directories when DEBUG=true' , async ( ) = > {
process . env . DEBUG = 'true'
mockReaddir . mockResolvedValue ( [ 'demo-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( { events : [ ] , metadata : { videoName : 'Demo' } } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'data.json' ) || path . endsWith ( 'recording.mp4' )
)
mockFetch . mockImplementation ( async ( input : string | URL ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_123' ,
projectId : 'project_123' ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if ( url . endsWith ( '/cli/upload/recording_123/recording' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
} )
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-06-11 16:56:28 +03:00
const result = await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-06-11 16:56:28 +03:00
)
expect ( result ) . toEqual ( {
projectId : 'project_123' ,
2026-06-12 20:11:08 +03:00
recordId : expect.any ( String ) ,
2026-06-11 16:56:28 +03:00
hadFailures : false ,
2026-07-06 00:54:35 +03:00
uploadedVideoNames : expect.any ( Array ) ,
2026-06-12 21:59:19 +03:00
studioNotices : [ ] ,
2026-07-01 17:43:51 +03:00
elevenLabsKeyMissingVideos : [ ] ,
2026-07-01 21:02:14 +03:00
notices : [ ] ,
2026-06-11 16:56:28 +03:00
failedVideoNames : [ ] ,
failedVideoMessages : [ ] ,
2026-06-15 01:31:02 +03:00
plan : null ,
2026-06-11 16:56:28 +03:00
} )
expect ( mockRmSync ) . not . toHaveBeenCalled ( )
} )
it ( 'uploads recordings in parallel and reports completions as they finish in CI mode' , async ( ) = > {
const stdoutWriteSpy = vi
. spyOn ( process . stdout , 'write' )
. mockImplementation ( ( ) = > true )
mockReaddir . mockResolvedValue ( [ 'slow-video' , 'fast-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'data.json' ) ) {
const videoName = pathString . includes ( '/slow-video/' )
? 'Slow Demo'
: 'Fast Demo'
return JSON . stringify ( { events : [ ] , metadata : { videoName } } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'data.json' ) || path . endsWith ( 'recording.mp4' )
)
mockFetch . mockImplementation (
async ( input : string | URL , init? : RequestInit ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
const body = JSON . parse ( String ( init ? . body ? ? '{}' ) ) as {
videoName? : string
}
if ( body . videoName === 'Slow Demo' ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_slow' ,
projectId : 'project_123' ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_fast' ,
projectId : 'project_123' ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if ( url . endsWith ( '/cli/upload/recording_slow/recording' ) ) {
await new Promise ( ( resolve ) = > setTimeout ( resolve , 20 ) )
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if ( url . endsWith ( '/cli/upload/recording_fast/recording' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
)
process . env . CI = 'true'
try {
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-06-11 16:56:28 +03:00
const result = await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-06-11 16:56:28 +03:00
)
expect ( result ) . toEqual ( {
projectId : 'project_123' ,
2026-06-12 20:11:08 +03:00
recordId : expect.any ( String ) ,
2026-06-11 16:56:28 +03:00
hadFailures : false ,
2026-07-06 00:54:35 +03:00
uploadedVideoNames : expect.any ( Array ) ,
2026-06-12 21:59:19 +03:00
studioNotices : [ ] ,
2026-07-01 17:43:51 +03:00
elevenLabsKeyMissingVideos : [ ] ,
2026-07-01 21:02:14 +03:00
notices : [ ] ,
2026-06-11 16:56:28 +03:00
failedVideoNames : [ ] ,
failedVideoMessages : [ ] ,
2026-06-15 01:31:02 +03:00
plan : null ,
2026-06-11 16:56:28 +03:00
} )
const messages = loggerInfoSpy . mock . calls . map ( ( call ) = > String ( call [ 0 ] ) )
expect ( messages ) . not . toContain ( 'Uploading 2 recordings in parallel...' )
expect (
messages . findIndex ( ( message ) = >
message . includes ( 'Uploaded "Fast Demo"' )
)
) . toBeLessThan (
messages . findIndex ( ( message ) = >
message . includes ( 'Uploaded "Slow Demo"' )
)
)
expect ( stdoutWriteSpy ) . not . toHaveBeenCalled ( )
} finally {
stdoutWriteSpy . mockRestore ( )
}
} )
2026-06-12 23:42:47 +03:00
it ( 'logs upload completions normally on interactive terminals' , async ( ) = > {
2026-06-11 16:56:28 +03:00
const stdoutWriteSpy = vi
. spyOn ( process . stdout , 'write' )
. mockImplementation ( ( ) = > true )
const originalIsTTY = Object . getOwnPropertyDescriptor (
process . stdout ,
'isTTY'
)
Object . defineProperty ( process . stdout , 'isTTY' , {
configurable : true ,
value : true ,
} )
delete process . env . CI
mockReaddir . mockResolvedValue ( [ 'demo-video' , 'second-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'data.json' ) ) {
const videoName = pathString . includes ( '/second-video/' )
? 'Second Demo'
: 'Demo'
return JSON . stringify ( { events : [ ] , metadata : { videoName } } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'data.json' ) || path . endsWith ( 'recording.mp4' )
)
mockFetch . mockImplementation (
async ( input : string | URL , init? : RequestInit ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
const body = JSON . parse ( String ( init ? . body ? ? '{}' ) ) as {
videoName? : string
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId :
body.videoName === 'Second Demo'
? 'recording_456'
: 'recording_123' ,
projectId : 'project_123' ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if (
url . endsWith ( '/cli/upload/recording_123/recording' ) ||
url . endsWith ( '/cli/upload/recording_456/recording' )
) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
)
try {
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-06-11 16:56:28 +03:00
await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-06-11 16:56:28 +03:00
)
2026-06-12 23:42:47 +03:00
const messages = loggerInfoSpy . mock . calls . map ( ( call ) = >
stripVTControlCharacters ( String ( call [ 0 ] ) )
)
expect ( messages ) . toContain ( '✔ Uploaded "Demo"' )
expect ( messages ) . toContain ( '✔ Uploaded "Second Demo"' )
expect ( stdoutWriteSpy ) . not . toHaveBeenCalled ( )
2026-06-11 16:56:28 +03:00
expect ( loggerInfoSpy ) . not . toHaveBeenCalledWith (
'Uploading 2 recordings in parallel...'
)
} finally {
stdoutWriteSpy . mockRestore ( )
if ( originalIsTTY ) {
Object . defineProperty ( process . stdout , 'isTTY' , originalIsTTY )
} else {
delete ( process . stdout as NodeJS . WriteStream & { isTTY? : boolean } )
. isTTY
}
}
} )
2026-06-12 23:42:47 +03:00
it ( 'logs assets without reserving upload rows on interactive terminals' , async ( ) = > {
2026-06-11 16:56:28 +03:00
const stdoutWriteSpy = vi
. spyOn ( process . stdout , 'write' )
. mockImplementation ( ( ) = > true )
const originalIsTTY = Object . getOwnPropertyDescriptor (
process . stdout ,
'isTTY'
)
Object . defineProperty ( process . stdout , 'isTTY' , {
configurable : true ,
value : true ,
} )
delete process . env . CI
mockReaddir . mockResolvedValue ( [ 'demo-video' , 'second-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'data.json' ) ) {
const videoName = pathString . includes ( '/second-video/' )
? 'Second Demo'
: 'Demo'
return JSON . stringify ( {
events : [
{
type : 'assetStart' ,
name : 'logo' ,
kind : 'image' ,
path : 'videos/logo.png' ,
durationMs : 1200 ,
fullScreen : false ,
} ,
] ,
metadata : { videoName } ,
} )
}
if ( pathString . endsWith ( 'videos/logo.png' ) ) {
return Buffer . from ( 'logo-bytes' )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'data.json' ) ||
path . endsWith ( 'recording.mp4' ) ||
path . endsWith ( 'videos/logo.png' )
)
mockFetch . mockImplementation (
async ( input : string | URL , init? : RequestInit ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
const body = JSON . parse ( String ( init ? . body ? ? '{}' ) ) as {
videoName? : string
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId :
body.videoName === 'Second Demo'
? 'recording_456'
: 'recording_123' ,
projectId : 'project_123' ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if ( url . endsWith ( '/asset/check' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { exists : true } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if (
url . endsWith ( '/cli/upload/recording_123/recording' ) ||
url . endsWith ( '/cli/upload/recording_456/recording' )
) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
)
try {
2026-07-04 18:13:26 +03:00
const { uploadRecordings , secretCredential } = await import ( './cli' )
2026-06-11 16:56:28 +03:00
await uploadRecordings (
'/repo/.screenci' ,
'Test Project' ,
'https://api.screenci.test' ,
2026-07-04 18:13:26 +03:00
secretCredential ( 'test-secret' )
2026-06-11 16:56:28 +03:00
)
const messages = loggerInfoSpy . mock . calls . map ( ( call ) = >
stripVTControlCharacters ( String ( call [ 0 ] ) )
)
2026-06-27 02:03:32 +03:00
expect ( messages ) . toContain ( '✔ Overlay already exists: videos/logo.png' )
2026-06-12 23:42:47 +03:00
expect ( messages ) . toContain ( '✔ Uploaded "Demo"' )
expect ( messages ) . toContain ( '✔ Uploaded "Second Demo"' )
expect ( stdoutWriteSpy ) . not . toHaveBeenCalled ( )
2026-06-11 16:56:28 +03:00
} finally {
stdoutWriteSpy . mockRestore ( )
if ( originalIsTTY ) {
Object . defineProperty ( process . stdout , 'isTTY' , originalIsTTY )
} else {
delete ( process . stdout as NodeJS . WriteStream & { isTTY? : boolean } )
. isTTY
}
}
} )
it ( 'warns when not all uploads succeed after a partial upload' , async ( ) = > {
process . argv = [
'node' ,
'cli.js' ,
2026-08-12 19:59:23 +03:00
'export' ,
2026-06-11 16:56:28 +03:00
'--config' ,
'test-fixtures/record-upload.config.ts' ,
]
mockReaddir . mockResolvedValue ( [ 'demo-video' , 'failed-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'record-upload.config.ts' ) ) {
return "export default { projectName: 'Test Project' }"
}
if ( pathString . endsWith ( 'data.json' ) ) {
const videoName = pathString . includes ( '/failed-video/' )
? 'Failed Demo'
: 'Demo'
return JSON . stringify ( { events : [ ] , metadata : { videoName } } )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'test-fixtures/record-upload.config.ts' ) ||
path . endsWith ( 'data.json' ) ||
path . endsWith ( 'recording.mp4' )
)
mockFetch . mockImplementation (
async ( input : string | URL , init? : RequestInit ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
const body = JSON . parse ( String ( init ? . body ? ? '{}' ) ) as {
videoName? : string
}
if ( body . videoName === 'Failed Demo' ) {
return {
ok : false ,
status : 402 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi
. fn ( )
. mockResolvedValue ( 'Upload limit reached for current plan.' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( {
recordingId : 'recording_123' ,
projectId : 'project_123' ,
} ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
if ( url . endsWith ( '/cli/upload/recording_123/recording' ) ) {
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
}
)
mockSpawn . mockImplementation ( ( ) = > {
process . nextTick ( ( ) = > mockChildProcess . emit ( 'close' , 0 ) )
return mockChildProcess as unknown as ChildProcess
} )
const { main } = await import ( './cli' )
await expect ( main ( ) ) . rejects . toThrow (
'Not all recordings succeeded to upload.'
)
expect ( loggerWarnSpy ) . toHaveBeenCalledWith (
'Failed Demo: Upload limit reached for current plan.'
)
expect ( loggerWarnSpy ) . toHaveBeenCalledWith (
2026-07-06 00:54:35 +03:00
"Not all recordings succeeded to upload. Failed videos: 'Failed Demo'. Some videos may be missing from the project."
2026-06-11 16:56:28 +03:00
)
2026-06-27 03:01:18 +03:00
const resultsInfoCall = loggerInfoSpy . mock . calls . findIndex ( ( call ) = >
stripVTControlCharacters ( String ( call [ 0 ] ) ) . includes (
'Results available at:'
)
)
2026-07-06 00:54:35 +03:00
expect ( resultsInfoCall ) . toBe ( - 1 )
2026-06-27 03:01:18 +03:00
expect (
2026-07-06 00:54:35 +03:00
mockWriteFile . mock . calls . some (
( [ path ] ) = >
typeof path === 'string' && path . endsWith ( 'last-record.json' )
)
) . toBe ( false )
2026-06-11 16:56:28 +03:00
} )
2026-07-12 02:29:16 +03:00
it ( 'warns with the video name and server message when an upload is refused' , async ( ) = > {
2026-06-11 16:56:28 +03:00
process . argv = [
'node' ,
'cli.js' ,
2026-08-12 19:59:23 +03:00
'export' ,
2026-06-11 16:56:28 +03:00
'--config' ,
'test-fixtures/record-upload.config.ts' ,
]
mockReaddir . mockResolvedValue ( [ 'failed-video' ] )
mockReadFile . mockImplementation ( async ( path : string | URL ) = > {
const pathString = String ( path )
if ( pathString . endsWith ( 'package.json' ) ) {
return JSON . stringify ( { version : '0.0.32' } )
}
if ( pathString . endsWith ( 'record-upload.config.ts' ) ) {
return "export default { projectName: 'Test Project' }"
}
if ( pathString . endsWith ( 'data.json' ) ) {
return JSON . stringify ( {
events : [ ] ,
metadata : { videoName : 'Find ScreenCI docs and getting started' } ,
} )
}
return ''
} )
mockExistsSync . mockImplementation (
( path : string ) = >
path . endsWith ( 'test-fixtures/record-upload.config.ts' ) ||
path . endsWith ( 'data.json' ) ||
path . endsWith ( 'recording.mp4' )
)
mockFetch . mockImplementation ( async ( input : string | URL ) = > {
const url = String ( input )
if ( url . endsWith ( '/cli/upload/start' ) ) {
return {
ok : false ,
status : 402 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi
. fn ( )
. mockResolvedValue (
2026-07-12 02:29:16 +03:00
'Your starter tier allows a single narration language across your organization, and this render would use 2. Upgrade your plan to render more languages at https://app.screenci.com/billing.'
2026-06-11 16:56:28 +03:00
) ,
}
}
return {
ok : true ,
status : 200 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi.fn ( ) . mockResolvedValue ( '' ) ,
}
} )
mockSpawn . mockImplementation ( ( ) = > {
process . nextTick ( ( ) = > mockChildProcess . emit ( 'close' , 0 ) )
return mockChildProcess as unknown as ChildProcess
} )
const { main } = await import ( './cli' )
await expect ( main ( ) ) . rejects . toThrow (
'Not all recordings succeeded to upload.'
)
expect ( loggerWarnSpy ) . toHaveBeenCalledWith (
2026-07-12 02:29:16 +03:00
'Find ScreenCI docs and getting started: Your starter tier allows a single narration language across your organization, and this render would use 2. Upgrade your plan to render more languages at https://app.screenci.com/billing.'
2026-06-11 16:56:28 +03:00
)
} )
it ( 'reports when all recordings failed' , async ( ) = > {
process . argv = [
'node' ,
'cli.js' ,
2026-08-12 19:59:23 +03:00
'export' ,
2026-06-11 16:56:28 +03:00
'--config' ,
'test-fixtures/record-upload-all-or-nothing.config.js' ,
]
mockReaddir . mockResolvedValue ( [ 'failed-video' ] )
mockExistsSync . mockImplementation ( ( path : string ) = >
path . endsWith ( 'test-fixtures/record-upload-all-or-nothing.config.js' )
)
mockSpawn . mockImplementation ( ( ) = > {
process . nextTick ( ( ) = > mockChildProcess . emit ( 'close' , 1 ) )
return mockChildProcess as unknown as ChildProcess
} )
const { main } = await import ( './cli' )
await expect ( main ( ) ) . rejects . toThrow ( 'Playwright exited with code 1' )
const messages = loggerInfoSpy . mock . calls . map ( ( call ) = > String ( call [ 0 ] ) )
expect ( messages ) . toContain ( 'All recordings failed.' )
expect ( messages ) . not . toContain (
'Some recordings failed, skipping upload because record.upload is "all-or-nothing".'
)
} )
2026-06-22 14:06:26 +03:00
describe ( '--remote' , ( ) = > {
it ( 'dispatches the workflow and does not record locally' , async ( ) = > {
2026-08-12 19:59:23 +03:00
process . argv = [ 'node' , 'cli.js' , 'export' , '--remote' ]
2026-06-22 14:06:26 +03:00
const { main } = await import ( './cli' )
await main ( )
// Pure dispatch: no Playwright child process is spawned.
expect ( mockSpawn ) . not . toHaveBeenCalled ( )
const triggerCall = mockFetch . mock . calls . find ( ( call ) = >
String ( call [ 0 ] ) . endsWith ( '/cli/trigger-run' )
)
expect ( triggerCall ) . toBeDefined ( )
const init = triggerCall ? . [ 1 ] as RequestInit
expect ( init . method ) . toBe ( 'POST' )
expect (
( init . headers as Record < string , string > ) [ 'X-ScreenCI-Secret' ]
) . toBe ( 'test-secret' )
expect ( JSON . parse ( String ( init . body ) ) ) . toEqual ( {
projectName : 'Test Project' ,
} )
const messages = loggerInfoSpy . mock . calls . map ( ( call ) = > String ( call [ 0 ] ) )
expect (
messages . some ( ( message ) = >
message . includes ( 'Triggered the remote recording workflow' )
)
) . toBe ( true )
} )
2026-06-22 15:16:06 +03:00
it ( 'forwards a --grep filter to the backend' , async ( ) = > {
process . argv = [
'node' ,
'cli.js' ,
2026-08-12 19:59:23 +03:00
'export' ,
2026-06-22 15:16:06 +03:00
'--remote' ,
'--grep' ,
'Onboarding' ,
]
const { main } = await import ( './cli' )
await main ( )
expect ( mockSpawn ) . not . toHaveBeenCalled ( )
const triggerCall = mockFetch . mock . calls . find ( ( call ) = >
String ( call [ 0 ] ) . endsWith ( '/cli/trigger-run' )
)
const init = triggerCall ? . [ 1 ] as RequestInit
expect ( JSON . parse ( String ( init . body ) ) ) . toEqual ( {
projectName : 'Test Project' ,
grep : 'Onboarding' ,
} )
} )
2026-06-22 14:06:26 +03:00
it ( 'throws when the backend rejects the trigger' , async ( ) = > {
2026-08-12 19:59:23 +03:00
process . argv = [ 'node' , 'cli.js' , 'export' , '--remote' ]
2026-06-22 14:06:26 +03:00
mockFetch . mockResolvedValue ( {
ok : false ,
status : 400 ,
json : vi.fn ( ) . mockResolvedValue ( { } ) ,
text : vi
. fn ( )
. mockResolvedValue (
'No GitHub repository is linked to this project.'
) ,
} )
const { main } = await import ( './cli' )
await expect ( main ( ) ) . rejects . toThrow ( 'Failed to trigger remote run' )
expect ( mockSpawn ) . not . toHaveBeenCalled ( )
} )
} )
2026-06-11 16:56:28 +03:00
} )
} )