import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createHash } from 'node:crypto' import type { ChildProcess } from 'child_process' import { EventEmitter } from 'events' import { Readable } from 'stream' import { stripVTControlCharacters } from 'util' import { logger } from './src/logger.js' // 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. 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() const mockRenameSync = vi.fn() const mockStatSync = vi.fn( () => undefined as undefined | { isDirectory: () => boolean } ) 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() const mockRm = vi.fn() 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, 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) && (call[2] as { cwd?: string }).cwd === cwd && 'stdio' in (call[2] as Record) && (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, 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) && (call[2] as { cwd?: string }).cwd === cwd && 'stdio' in (call[2] as Record) && (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, 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) && (call[2] as { cwd?: string }).cwd === cwd && 'stdio' in (call[2] as Record) && (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, renameSync: mockRenameSync, statSync: mockStatSync, readdirSync: mockReaddirSync, readFileSync: mockReadFileSync, default: { createReadStream: mockCreateReadStream, existsSync: mockExistsSync, realpathSync: mockRealpathSync, mkdirSync: mockMkdirSync, rmSync: mockRmSync, renameSync: mockRenameSync, statSync: mockStatSync, readdirSync: mockReaddirSync, readFileSync: mockReadFileSync, }, })) vi.mock('fs/promises', () => ({ appendFile: mockAppendFile, rm: mockRm, readdir: mockReaddir, readFile: mockReadFile, stat: mockStat, writeFile: mockWriteFile, mkdir: mockMkdir, default: { appendFile: mockAppendFile, rm: mockRm, 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 let loggerInfoSpy: ReturnType let loggerWarnSpy: ReturnType let processExitSpy: ReturnType let loadEnvFileSpy: ReturnType | 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) mockRm.mockResolvedValue(undefined) 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 }) mockStatSync.mockReturnValue(undefined) mockReaddirSync.mockReturnValue([] as unknown as string[]) 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() }) describe('clearRecordingDirectories', () => { it('wipes per-recording directories but preserves the overlay cache', async () => { 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]') // The cross-run overlay cache survives the wipe so unchanged overlays are // not re-rendered, re-encoded, and re-uploaded. expect(removed).not.toContain('/project/.screenci/.overlay-cache') }) it('keeps a recording dir data.json as last-data.json for the freshness check', async () => { const { clearRecordingDirectories } = await import('./cli') const dir = '/project/.screenci' 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') ) clearRecordingDirectories(dir) const removed = mockRmSync.mock.calls.map((call) => call[0] as string) // 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' ) }) it('preserves the anon trial token so one trial spans runs (cap/claim/graduate stay intact)', async () => { const { clearRecordingDirectories } = await import('./cli') const dir = '/project/.screenci' mockReaddirSync.mockReturnValue([ 'My Video [en]', 'anon-session.json', ] 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]') // 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') }) }) 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() }) }) describe('export command', () => { beforeEach(() => { process.env.SCREENCI_SECRET = 'test-secret' }) afterEach(() => { // Export sets process.exitCode on failed/empty runs; never leak it into // the test runner process. process.exitCode = undefined }) it('refuses an anonymous export before recording (exports are account-only)', async () => { delete process.env.SCREENCI_SECRET process.argv = ['node', 'cli.js', 'export'] mockSpawn.mockImplementation(() => { process.nextTick(() => mockChildProcess.emit('close', 0)) return mockChildProcess as unknown as ChildProcess }) const { main } = await import('./cli') // 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') 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) }) it('loads SCREENCI_SECRET from the project .env when envFile is not configured', async () => { delete process.env.SCREENCI_SECRET process.argv = ['node', 'cli.js', 'export'] 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 () => { process.argv = ['node', 'cli.js', 'export'] 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', }), stdio: 'inherit', }) ) }) it('fails the run without printing a results URL when only an unrelated sibling recording is uploadable', async () => { process.argv = [ 'node', 'cli.js', 'export', '--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"' ) }) it('should only log the config path in verbose mode', async () => { process.argv = ['node', 'cli.js', 'export', '--verbose'] 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 }) const { uploadRecordings, secretCredential } = await import('./cli') const result = await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) expect(result).toEqual({ projectId: 'project_123', recordId: expect.any(String), hadFailures: false, uploadedVideoNames: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], failedVideoNames: [], failedVideoMessages: [], plan: null, }) expect(mockReaddir).toHaveBeenCalledWith('/repo/.screenci') expect(mockReadFile).toHaveBeenCalledWith( expect.stringContaining('/repo/.screenci/demo-video/data.json'), 'utf-8' ) }) it('hard-fails a video whose upload is rejected for a missing ElevenLabs key', 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') ) // 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, }) mockFetch.mockImplementation(async (input: string | URL) => { const url = String(input) if (url.endsWith('/cli/upload/start')) { return { ok: false, status: 422, json: vi.fn().mockResolvedValue(JSON.parse(errorBody)), text: vi.fn().mockResolvedValue(errorBody), } } return { ok: true, status: 200, json: vi.fn().mockResolvedValue({}), text: vi.fn().mockResolvedValue(''), } }) const { uploadRecordings, secretCredential } = await import('./cli') const result = await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) expect(result.elevenLabsKeyMissingVideos).toEqual(['Demo']) 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([]) }) 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(''), } }) const { uploadRecordings, secretCredential } = await import('./cli') const result = await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) expect(result.notices).toEqual([ 'Heads up: rendering may take a little longer today.', ]) }) it('prints the result URL without an upgrade mention for free plans', async () => { process.argv = [ 'node', 'cli.js', 'export', '--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: 'free', }), text: vi.fn().mockResolvedValue(''), } } 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)), } } 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( 'Recording finished, export render in progress. Results available at:' ) expect( messages.some((message) => message.includes('ScreenCI watermark')) ).toBe(false) expect(messages.some((message) => message.includes('/select-plan'))).toBe( false ) }) it('points an anonymous export at edit and sign-up without touching the trial session', async () => { delete process.env.SCREENCI_SECRET process.env.SCREENCI_ENVIRONMENT = 'local' process.argv = [ 'node', 'cli.js', 'export', '--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') await expect(main()).rejects.toThrow('process.exit called') 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])) ) const refusal = errors.find((message) => message.includes('Exporting requires an account') ) 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) => String(call[0]).endsWith('anon-session.json') ) expect( anonSessionDeletes.every((call) => String(call[1]).includes('token')) ).toBe(true) }) it('omits the upgrade mention for business plans', async () => { process.argv = [ 'node', 'cli.js', 'export', '--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(''), } } 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)), } } 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( 'Recording finished, export render in progress. Results available at:' ) expect(messages.some((message) => message.includes('/select-plan'))).toBe( false ) }) 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(''), } }) const { uploadRecordings, secretCredential } = await import('./cli') const result = await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) expect(result.studioNotices).toEqual([ { videoName: 'Demo', videoId: 'video_123', studio: { held: true }, }, ]) }) 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(''), } } return { ok: true, status: 200, json: vi.fn().mockResolvedValue({}), text: vi.fn().mockResolvedValue(''), } }) const { uploadRecordings, secretCredential } = await import('./cli') const result = await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) expect(result.studioNotices).toEqual([ { videoName: 'Demo', videoId: 'video_123', studio: { applied: true }, }, ]) }) it('formats studio URLs', async () => { const { formatStudioUrl } = await import('./cli') expect( formatStudioUrl('https://app.screenci.test', 'project_1', 'video_2') ).toBe('https://app.screenci.test/project/project_1/video/video_2?editor') }) 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. 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(''), } }) const { uploadRecordings, secretCredential } = await import('./cli') await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) const startCall = mockFetch.mock.calls.find( ([url]) => String(url) === 'https://api.screenci.test/cli/upload/start' ) 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' ) expect(startCall?.[1].headers).toMatchObject({ 'X-ScreenCI-Secret': 'test-secret', }) }) 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(''), } }) const { uploadRecordings, secretCredential } = await import('./cli') await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) const sentHeaders = mockFetch.mock.calls.flatMap((call) => { const init = call[1] as { headers?: Record } | 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') } }) it('uploads completed recordings after partial failure with default policy, then still fails', async () => { process.argv = [ 'node', 'cli.js', 'export', '--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.' ) // 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') ) const removed = mockRmSync.mock.calls.map((call) => String(call[0])) expect( removed.some((path) => path.endsWith('demo-video/data.json')) ).toBe(false) }) it('skips upload after partial failure with all-or-nothing policy, then still fails', async () => { process.argv = [ 'node', 'cli.js', 'export', '--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') ) const { uploadRecordings, secretCredential } = await import('./cli') const result = await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) expect(result).toEqual({ projectId: null, recordId: null, hadFailures: false, uploadedVideoNames: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], failedVideoNames: [], failedVideoMessages: [], plan: null, }) 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') ) const { uploadRecordings, secretCredential } = await import('./cli') const result = await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) expect(result).toEqual({ projectId: null, recordId: expect.any(String), hadFailures: true, uploadedVideoNames: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], failedVideoNames: ['Demo'], failedVideoMessages: [ { videoName: 'Demo', message: 'Missing recording.mp4 for "Demo"', }, ], plan: null, }) expect(mockFetch).not.toHaveBeenCalledWith( expect.stringContaining('/cli/upload/start'), expect.anything() ) }) 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 let startBody: Record | 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)) 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(''), } } ) const { uploadRecordings, secretCredential } = await import('./cli') const result = await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) expect(result).toEqual({ projectId: 'project_123', recordId: expect.any(String), hadFailures: false, uploadedVideoNames: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], 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 | undefined)?.[ 'Content-Type' ] ).toBe('image/png') // The screenshot upload declares how many screenshots this run produced so // the backend can batch them onto one machine. expect(startBody?.expectedScreenshotCount).toBe(1) }) 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(''), } }) const { uploadRecordings, secretCredential } = await import('./cli') const result = await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) expect(result).toEqual({ projectId: 'project_123', recordId: expect.any(String), hadFailures: true, uploadedVideoNames: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], failedVideoNames: ['Demo'], failedVideoMessages: [ { videoName: 'Demo', message: 'Failed to check asset videos/logo.png: 500 backend exploded', }, ], plan: null, }) expect( mockFetch.mock.calls.some(([input]) => String(input).endsWith('/cli/upload/recording_123/recording') ) ).toBe(false) }) 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(''), } }) const { uploadRecordings, secretCredential } = await import('./cli') const result = await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) // The failure message uses the cwd-relative path, not the absolute one. expect(result.failedVideoMessages).toEqual([ { videoName: 'Demo', message: 'Failed to upload asset .screenci/Demo/generated/ring.png: 500 Upload failed', }, ]) cwdSpy.mockRestore() }) 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', 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 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(''), } } ) const { uploadRecordings, secretCredential } = await import('./cli') const result = await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) expect(result).toEqual({ projectId: 'project_123', recordId: expect.any(String), hadFailures: false, uploadedVideoNames: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], failedVideoNames: [], failedVideoMessages: [], plan: null, }) expect(startBody?.expectedAssets).toEqual([ expect.objectContaining({ fileHash: expect.any(String), path: './asset.mp4', size: Buffer.from('nested-asset').byteLength, }), ]) }) 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(''), } } ) const { uploadRecordings, secretCredential } = await import('./cli') await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) 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 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) }) 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(''), } }) const { uploadRecordings, secretCredential } = await import('./cli') const result = await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) expect(result).toEqual({ projectId: 'project_123', recordId: expect.any(String), hadFailures: true, uploadedVideoNames: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], failedVideoNames: ['Failed Demo'], failedVideoMessages: [ { videoName: 'Failed Demo', message: 'Upload limit reached for current plan.', }, ], plan: null, }) }) 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() ) }) 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. 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) => path.endsWith('data.json') || path.endsWith('recording.mp4') ) const { uploadRecordings, collapseFailedVideoWarnings, anonCredential } = await import('./cli') const result = await uploadRecordings( '/tmp/.screenci', 'Test Project', 'http://localhost:8787', anonCredential('anon-token-1') ) expect(result.hadFailures).toBe(true) 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( 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, ]) }) it('removes uploaded media but keeps data.json after successful upload', async () => { mockReaddir.mockResolvedValue(['demo-video']) mockReaddirSync.mockReturnValue([ 'data.json', 'recording.mp4', ] as unknown as string[]) 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(''), } }) const { uploadRecordings, secretCredential } = await import('./cli') const result = await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) expect(result).toEqual({ projectId: 'project_123', recordId: expect.any(String), hadFailures: false, uploadedVideoNames: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], failedVideoNames: [], failedVideoMessages: [], plan: null, }) // 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') }) 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(''), } }) const { uploadRecordings, secretCredential } = await import('./cli') const result = await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) expect(result).toEqual({ projectId: 'project_123', recordId: expect.any(String), hadFailures: false, uploadedVideoNames: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], failedVideoNames: [], failedVideoMessages: [], plan: null, }) 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 { const { uploadRecordings, secretCredential } = await import('./cli') const result = await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) expect(result).toEqual({ projectId: 'project_123', recordId: expect.any(String), hadFailures: false, uploadedVideoNames: expect.any(Array), studioNotices: [], elevenLabsKeyMissingVideos: [], notices: [], failedVideoNames: [], failedVideoMessages: [], plan: null, }) 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() } }) it('logs upload completions normally on interactive terminals', async () => { 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 { const { uploadRecordings, secretCredential } = await import('./cli') await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) 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() 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 } } }) it('logs assets without reserving upload rows on interactive terminals', async () => { 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 { const { uploadRecordings, secretCredential } = await import('./cli') await uploadRecordings( '/repo/.screenci', 'Test Project', 'https://api.screenci.test', secretCredential('test-secret') ) const messages = loggerInfoSpy.mock.calls.map((call) => stripVTControlCharacters(String(call[0])) ) expect(messages).toContain('✔ Overlay already exists: videos/logo.png') expect(messages).toContain('✔ Uploaded "Demo"') expect(messages).toContain('✔ Uploaded "Second Demo"') expect(stdoutWriteSpy).not.toHaveBeenCalled() } 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', 'export', '--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( "Not all recordings succeeded to upload. Failed videos: 'Failed Demo'. Some videos may be missing from the project." ) const resultsInfoCall = loggerInfoSpy.mock.calls.findIndex((call) => stripVTControlCharacters(String(call[0])).includes( 'Results available at:' ) ) expect(resultsInfoCall).toBe(-1) expect( mockWriteFile.mock.calls.some( ([path]) => typeof path === 'string' && path.endsWith('last-record.json') ) ).toBe(false) }) it('warns with the video name and server message when an upload is refused', async () => { process.argv = [ 'node', 'cli.js', 'export', '--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( '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.' ), } } 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( '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.' ) }) it('reports when all recordings failed', async () => { process.argv = [ 'node', 'cli.js', 'export', '--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".' ) }) describe('--remote', () => { it('dispatches the workflow and does not record locally', async () => { process.argv = ['node', 'cli.js', 'export', '--remote'] 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)['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) }) it('forwards a --grep filter to the backend', async () => { process.argv = [ 'node', 'cli.js', 'export', '--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', }) }) it('throws when the backend rejects the trigger', async () => { process.argv = ['node', 'cli.js', 'export', '--remote'] 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() }) }) }) })