mirror of
https://github.com/screenci/screenci.git
synced 2026-09-19 08:57:46 +08:00
9a3966d650
Add `screenci export --select`: each finished render becomes the served version of its language. Off by default, never implied in CI; the init workflow template shows it on the export line. Every upload now reports where the CLI runs (`runner: 'ci' | 'local'`, from the usual CI environment variables with a `SCREENCI_CI=1/0` override), on upload start and on the preview-started ping, so the service can attribute exports and keep CI previews apart from each person's own preview. Docs: cli reference, version history, public URLs, CI setup, editor, and the agent skill describe selection without the auto-select toggle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KQgcfo9BeSAKAQL8JsEjJC
3568 lines
116 KiB
TypeScript
3568 lines
116 KiB
TypeScript
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<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,
|
|
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<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)
|
|
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 recording run 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 --share combined with --no-wait (sharing needs finished renders)', async () => {
|
|
process.argv = ['node', 'cli.js', 'export', '--share', '--no-wait']
|
|
|
|
const { main } = await import('./cli')
|
|
|
|
await expect(main()).rejects.toThrow('process.exit called')
|
|
expect(processExitSpy).toHaveBeenCalledWith(1)
|
|
const errors = loggerErrorSpy.mock.calls.map((call) =>
|
|
stripVTControlCharacters(String(call[0]))
|
|
)
|
|
expect(
|
|
errors.some((message) =>
|
|
message.includes('--share needs finished renders')
|
|
)
|
|
).toBe(true)
|
|
// Refused before any recording or upload work started.
|
|
expect(mockSpawn).not.toHaveBeenCalled()
|
|
})
|
|
|
|
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),
|
|
uploadedVideos: expect.any(Array),
|
|
uploadedPassCount: expect.any(Number),
|
|
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('exits after the upload without polling or downloading when --no-wait is set', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'export',
|
|
'--no-wait',
|
|
'--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 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).toContain(
|
|
'Not waiting for renders (--no-wait). Track progress and download from the URL above.'
|
|
)
|
|
expect(messages).not.toContain('Waiting for renders to finish...')
|
|
const fetchedUrls = mockFetch.mock.calls.map((call) => String(call[0]))
|
|
expect(fetchedUrls.some((url) => url.includes('/cli/info'))).toBe(false)
|
|
expect(fetchedUrls.some((url) => url.includes('/cli/download/'))).toBe(
|
|
false
|
|
)
|
|
// Upload succeeded and no render was awaited: the run is a success.
|
|
expect(process.exitCode).toBeUndefined()
|
|
})
|
|
|
|
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',
|
|
baseVideoName: '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',
|
|
baseVideoName: 'Demo',
|
|
videoId: 'video_123',
|
|
studio: { applied: true },
|
|
},
|
|
])
|
|
})
|
|
|
|
it('reports declared (base) names for multi-language uploads', async () => {
|
|
// Two per-language passes of one video: the display names carry [lang]
|
|
// suffixes, but uploadedVideoNames must dedupe to the declared name so
|
|
// export's requested-vs-uploaded success check can ever match.
|
|
mockReaddir.mockResolvedValue(['demo-en', 'demo-fi'])
|
|
mockReadFile.mockImplementation(async (path: string | URL) => {
|
|
const pathString = String(path)
|
|
if (pathString.endsWith('package.json')) {
|
|
return JSON.stringify({ version: '0.0.32' })
|
|
}
|
|
if (pathString.includes('demo-en')) {
|
|
return JSON.stringify({
|
|
events: [],
|
|
metadata: { videoName: 'Demo', languages: ['en'] },
|
|
})
|
|
}
|
|
if (pathString.includes('demo-fi')) {
|
|
return JSON.stringify({
|
|
events: [],
|
|
metadata: { videoName: 'Demo', languages: ['fi'] },
|
|
})
|
|
}
|
|
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',
|
|
}),
|
|
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.hadFailures).toBe(false)
|
|
expect(result.uploadedVideoNames).toEqual(['Demo'])
|
|
})
|
|
|
|
it('formats video overview URLs', async () => {
|
|
const { formatPreviewUrl } = await import('./cli')
|
|
|
|
expect(
|
|
formatPreviewUrl('https://app.screenci.test', 'project_1', 'video_2')
|
|
).toBe('https://app.screenci.test/project/project_1/video/video_2')
|
|
})
|
|
|
|
it('formats single-video export URLs with the run preselected', async () => {
|
|
const { formatVideoExportUrl } = await import('./cli')
|
|
|
|
expect(
|
|
formatVideoExportUrl(
|
|
'https://app.screenci.test',
|
|
'project_1',
|
|
'video_2',
|
|
'rec_3'
|
|
)
|
|
).toBe(
|
|
'https://app.screenci.test/project/project_1/video/video_2?export=rec_3'
|
|
)
|
|
})
|
|
|
|
it('stamps the run context source bundle onto every upload start', 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',
|
|
}),
|
|
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'),
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
{ sourceBundleId: 'sb_42' }
|
|
)
|
|
|
|
const startCall = mockFetch.mock.calls.find(
|
|
([url]) => String(url) === 'https://api.screenci.test/cli/upload/start'
|
|
)
|
|
expect(JSON.parse(startCall?.[1].body as string)).toMatchObject({
|
|
projectName: 'Test Project',
|
|
videoName: 'Demo',
|
|
sourceBundleId: 'sb_42',
|
|
})
|
|
|
|
// Without a run context the field is absent, not null.
|
|
mockFetch.mockClear()
|
|
await uploadRecordings(
|
|
'/repo/.screenci',
|
|
'Test Project',
|
|
'https://api.screenci.test',
|
|
secretCredential('test-secret')
|
|
)
|
|
const plainCall = mockFetch.mock.calls.find(
|
|
([url]) => String(url) === 'https://api.screenci.test/cli/upload/start'
|
|
)
|
|
expect(JSON.parse(plainCall?.[1].body as string)).not.toHaveProperty(
|
|
'sourceBundleId'
|
|
)
|
|
})
|
|
|
|
it('stamps the runner kind and the --select flag onto every upload start', 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',
|
|
}),
|
|
text: vi.fn().mockResolvedValue(''),
|
|
}
|
|
}
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: vi.fn().mockResolvedValue({}),
|
|
text: vi.fn().mockResolvedValue(''),
|
|
}
|
|
})
|
|
|
|
const { uploadRecordings, secretCredential } = await import('./cli')
|
|
const startBody = () => {
|
|
const call = mockFetch.mock.calls.find(
|
|
([url]) =>
|
|
String(url) === 'https://api.screenci.test/cli/upload/start'
|
|
)
|
|
return JSON.parse(call?.[1].body as string) as Record<string, unknown>
|
|
}
|
|
|
|
// A GitHub Actions run reports itself as CI; --select rides along.
|
|
process.env.GITHUB_ACTIONS = 'true'
|
|
delete process.env.SCREENCI_CI
|
|
await uploadRecordings(
|
|
'/repo/.screenci',
|
|
'Test Project',
|
|
'https://api.screenci.test',
|
|
secretCredential('test-secret'),
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
{ sourceBundleId: null, select: true }
|
|
)
|
|
expect(startBody()).toMatchObject({ runner: 'ci', select: true })
|
|
|
|
// SCREENCI_CI=0 overrides the environment detection; without --select
|
|
// the field is absent, not false.
|
|
mockFetch.mockClear()
|
|
process.env.SCREENCI_CI = '0'
|
|
await uploadRecordings(
|
|
'/repo/.screenci',
|
|
'Test Project',
|
|
'https://api.screenci.test',
|
|
secretCredential('test-secret')
|
|
)
|
|
expect(startBody()).toMatchObject({ runner: 'local' })
|
|
expect(startBody()).not.toHaveProperty('select')
|
|
})
|
|
|
|
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<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')
|
|
}
|
|
})
|
|
|
|
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),
|
|
uploadedVideos: expect.any(Array),
|
|
uploadedPassCount: expect.any(Number),
|
|
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),
|
|
uploadedVideos: expect.any(Array),
|
|
uploadedPassCount: expect.any(Number),
|
|
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<string, unknown> | 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),
|
|
uploadedVideos: expect.any(Array),
|
|
uploadedPassCount: expect.any(Number),
|
|
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<string, string> | 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),
|
|
uploadedVideos: expect.any(Array),
|
|
uploadedPassCount: expect.any(Number),
|
|
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),
|
|
uploadedVideos: expect.any(Array),
|
|
uploadedPassCount: expect.any(Number),
|
|
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<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)
|
|
})
|
|
|
|
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),
|
|
uploadedVideos: expect.any(Array),
|
|
uploadedPassCount: expect.any(Number),
|
|
studioNotices: [],
|
|
elevenLabsKeyMissingVideos: [],
|
|
notices: [],
|
|
failedVideoNames: ['Failed Demo'],
|
|
failedVideoMessages: [
|
|
{
|
|
videoName: 'Failed Demo',
|
|
message: 'Upload limit reached for current plan.',
|
|
},
|
|
],
|
|
plan: null,
|
|
})
|
|
})
|
|
|
|
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),
|
|
uploadedVideos: expect.any(Array),
|
|
uploadedPassCount: expect.any(Number),
|
|
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),
|
|
uploadedVideos: expect.any(Array),
|
|
uploadedPassCount: expect.any(Number),
|
|
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),
|
|
uploadedVideos: expect.any(Array),
|
|
uploadedPassCount: expect.any(Number),
|
|
uploadedPassCount: expect.any(Number),
|
|
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<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)
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|
|
})
|
|
})
|