feat(screenci): add all-or-nothing recording config

This commit is contained in:
Olli Paloviita
2026-05-22 21:37:15 +03:00
parent 3fdf26545e
commit 5ad660aa8c
16 changed files with 620 additions and 77 deletions
+1 -1
View File
@@ -105,7 +105,7 @@ export default defineConfig({
recordOptions: {
aspectRatio: '16:9', // '16:9' | '9:16' | '1:1' | '4:3' | ...
quality: '1080p', // '720p' | '1080p' | '1440p' | '2160p'
fps: 30, // 24 | 30 | 60
fps: 60, // 24 | 30 | 60
},
},
})
+281 -1
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { ChildProcess } from 'child_process'
import { EventEmitter } from 'events'
import { Readable } from 'stream'
import pc from 'picocolors'
import { logger } from './src/logger.js'
import type { VoiceKey } from './src/voices.js'
@@ -118,6 +119,15 @@ describe('CLI', () => {
}
return ''
})
mockStat.mockResolvedValue({ size: 4 })
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 ?? ''
@@ -264,6 +274,248 @@ describe('CLI', () => {
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 } = await import('./cli')
await uploadRecordings(
'/repo/.screenci',
'Test Project',
'https://api.screenci.test',
'test-secret'
)
expect(mockReaddir).toHaveBeenCalledWith('/repo/.screenci')
expect(mockReadFile).toHaveBeenCalledWith(
expect.stringContaining('/repo/.screenci/demo-video/data.json'),
'utf-8'
)
})
it('uploads completed recordings after partial failure with default policy, then still fails', async () => {
process.argv = [
'node',
'cli.js',
'record',
'--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(loggerInfoSpy).toHaveBeenCalledWith(
'Some recordings failed, uploading successful videos only.'
)
})
it('skips upload after partial failure with all-or-nothing policy, then still fails', async () => {
process.argv = [
'node',
'cli.js',
'record',
'--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 } = await import('./cli')
await uploadRecordings(
'/repo/.screenci',
'Test Project',
'https://api.screenci.test',
'test-secret'
)
expect(mockFetch).not.toHaveBeenCalled()
})
it('reports when all recordings failed', async () => {
process.argv = [
'node',
'cli.js',
'record',
'--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')
expect(loggerInfoSpy).toHaveBeenCalledWith('All recordings failed.')
expect(loggerInfoSpy).not.toHaveBeenCalledWith(
'Some recordings failed, skipping upload because record.upload is "all-or-nothing".'
)
})
})
describe('test command', () => {
@@ -587,7 +839,7 @@ describe('CLI', () => {
)
expect(loggerInfoSpy).toHaveBeenCalledWith(
expect.stringContaining(
'https://docs.screenci.com/reference/cli/#screenci-test-playwrightargs'
'https://screenci.com/docs/reference/cli/#--mock-record'
)
)
})
@@ -854,6 +1106,20 @@ describe('CLI', () => {
expect(extractConfigStringLiteral(configSource, 'envFile')).toBe('./.env')
})
it('should extract record upload policy literals', async () => {
const { extractRecordUploadPolicyLiteral } = await import('./cli')
const configSource = `export default defineConfig({
projectName: 'Quoted Project',
record: {
upload: 'all-or-nothing',
},
})`
expect(extractRecordUploadPolicyLiteral(configSource)).toBe(
'all-or-nothing'
)
})
})
describe('upload annotation helpers', () => {
@@ -1610,6 +1876,20 @@ describe('CLI', () => {
expect(configCall?.[1]).toContain("envFile: '.env'")
})
it('should default generated screenci.config.ts to 60 fps', async () => {
process.argv = ['node', 'cli.js', 'init', 'my-project']
mockExistsSync.mockReturnValue(false)
const { main } = await import('./cli')
await main()
const configCall = mockWriteFile.mock.calls.find(
(c: unknown[]) =>
typeof c[0] === 'string' && c[0].endsWith('screenci.config.ts')
)
expect(configCall?.[1]).toContain('fps: 60')
})
it('should generate an example video that walks through ScreenCI docs', async () => {
process.argv = ['node', 'cli.js', 'init', 'my-project']
mockExistsSync.mockReturnValue(false)
+121 -36
View File
@@ -29,12 +29,13 @@ import {
SCREENCI_DISABLE_RECORDING_TIMINGS_ENV,
SCREENCI_MOCK_RECORD_ENV,
} from './src/runtimeMode.js'
import { DEFAULT_RECORD_UPLOAD_POLICY } from './src/defaults.js'
import type { VoiceKey } from './src/voices.js'
import type { ScreenCIConfig } from './src/types.js'
import type { RecordUploadPolicy, ScreenCIConfig } from './src/types.js'
const SCREENCI_DOCS_URL = 'https://screenci.com/docs/intro/'
const SCREENCI_MOCK_RECORD_DOCS_URL =
'https://docs.screenci.com/reference/cli/#screenci-test-playwrightargs'
'https://screenci.com/docs/reference/cli/#--mock-record'
type ProjectInfoVideo = {
name: string
@@ -804,7 +805,7 @@ async function uploadAssets(
}
}
async function uploadRecordings(
export async function uploadRecordings(
screenciDir: string,
projectName: string,
apiUrl: string,
@@ -984,6 +985,19 @@ async function uploadRecordings(
}
}
async function countCompletedRecordings(screenciDir: string): Promise<number> {
let entries: string[]
try {
entries = await readdir(screenciDir)
} catch {
return 0
}
return entries.filter((entry) =>
existsSync(resolve(screenciDir, entry, 'data.json'))
).length
}
export function getDevBackendUrl(): string {
const devBackendPort = process.env.DEV_BACKEND_PORT
return devBackendPort
@@ -1096,9 +1110,39 @@ export function extractConfigStringLiteral(
return templateLiteralMatch?.[1]
}
async function tryReadConfigFromSource(
resolvedConfigPath: string
): Promise<Pick<ScreenCIConfig, 'projectName'> & { envFile?: string }> {
export function extractRecordUploadPolicyLiteral(
configSource: string
): RecordUploadPolicy | undefined {
const singleQuoteMatch = configSource.match(
/record\s*:\s*\{[\s\S]*?upload\s*:\s*'(passed-only|all-or-nothing)'/
)
if (singleQuoteMatch) {
return singleQuoteMatch[1] as RecordUploadPolicy
}
const doubleQuoteMatch = configSource.match(
/record\s*:\s*\{[\s\S]*?upload\s*:\s*"(passed-only|all-or-nothing)"/
)
if (doubleQuoteMatch) {
return doubleQuoteMatch[1] as RecordUploadPolicy
}
const templateLiteralMatch = configSource.match(
/record\s*:\s*\{[\s\S]*?upload\s*:\s*`(passed-only|all-or-nothing)`/
)
return templateLiteralMatch?.[1] as RecordUploadPolicy | undefined
}
function resolveRecordUploadPolicy(config: ScreenCIConfig): RecordUploadPolicy {
return config.record?.upload ?? DEFAULT_RECORD_UPLOAD_POLICY
}
async function tryReadConfigFromSource(resolvedConfigPath: string): Promise<
Pick<ScreenCIConfig, 'projectName'> & {
envFile?: string
record?: { upload?: RecordUploadPolicy }
}
> {
const configSource = await readFile(resolvedConfigPath, 'utf-8')
const projectName = extractConfigStringLiteral(configSource, 'projectName')
@@ -1109,10 +1153,12 @@ async function tryReadConfigFromSource(
}
const envFile = extractConfigStringLiteral(configSource, 'envFile')
const recordUpload = extractRecordUploadPolicyLiteral(configSource)
return {
projectName,
...(envFile !== undefined ? { envFile } : {}),
...(recordUpload !== undefined ? { record: { upload: recordUpload } } : {}),
}
}
@@ -1136,16 +1182,22 @@ async function loadRecordConfigWithoutPlaywrightCollision(
)
return configModule.default as ScreenCIConfig
} catch (err) {
if (
const hasPlaywrightCollision =
err instanceof Error &&
err.message.includes('Requiring @playwright/test second time')
) {
if (hasPlaywrightCollision) {
logger.warn(
'Playwright was loaded from multiple module paths. Falling back to static config parsing for upload metadata.'
)
}
try {
return (await tryReadConfigFromSource(
resolvedConfigPath
)) as ScreenCIConfig
} catch {
// Preserve the original import error when static parsing cannot recover.
}
throw err
@@ -1240,7 +1292,7 @@ export default defineConfig({
recordOptions: {
aspectRatio: '16:9',
quality: '1080p',
fps: 30,
fps: 60,
},
},
projects: [
@@ -1844,12 +1896,20 @@ export async function main() {
.allowUnknownOption(true)
.action(async () => {
const parsed = parseRecordCliArgs(getSubcommandArgv('record'))
let playwrightFailure: Error | null = null
try {
await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose)
} catch (error) {
logRecordFailureHint()
throw error
if (
error instanceof Error &&
error.message.startsWith('Playwright exited with code ')
) {
playwrightFailure = error
} else {
throw error
}
}
if (process.env.SCREENCI_RECORDING === 'true') return
@@ -1870,42 +1930,67 @@ export async function main() {
const apiUrl = getDevBackendUrl()
const appUrl = getDevFrontendUrl()
const secret = process.env.SCREENCI_SECRET
if (!secret) {
const uploadPolicy = resolveRecordUploadPolicy(screenciConfig)
const configDir = dirname(resolvedConfigPath)
const screenciDir = resolve(configDir, '.screenci')
const completedRecordingCount =
await countCompletedRecordings(screenciDir)
if (playwrightFailure !== null && completedRecordingCount === 0) {
logger.info(pc.yellow('All recordings failed.'))
} else if (!secret) {
logger.info(
'No secret configured, skipping upload. Set SCREENCI_SECRET in your .env file.'
)
return
}
const configDir = dirname(resolvedConfigPath)
const screenciDir = resolve(configDir, '.screenci')
let projectId: string | null = null
try {
logger.info('')
projectId = await uploadRecordings(
screenciDir,
screenciConfig.projectName,
apiUrl,
secret
)
} catch (err) {
if (isUploadCancelledError(err)) {
process.exit(130)
}
throw err
}
if (projectId !== null) {
const projectUrl = `${appUrl}/project/${projectId}`
await writeGitHubProjectOutput(projectUrl)
logger.info('')
} else if (
playwrightFailure !== null &&
uploadPolicy === 'all-or-nothing'
) {
logger.info(
'Recording finished, rendering in progress. Results available at:'
'Some recordings failed, skipping upload because record.upload is "all-or-nothing".'
)
logger.info(pc.cyan(projectUrl))
} else {
if (playwrightFailure !== null && uploadPolicy === 'passed-only') {
logger.info(
pc.yellow(
'Some recordings failed, uploading successful videos only.'
)
)
}
let projectId: string | null = null
try {
logger.info('')
projectId = await uploadRecordings(
screenciDir,
screenciConfig.projectName,
apiUrl,
secret
)
} catch (err) {
if (isUploadCancelledError(err)) {
process.exit(130)
}
throw err
}
if (projectId !== null) {
const projectUrl = `${appUrl}/project/${projectId}`
await writeGitHubProjectOutput(projectUrl)
logger.info('')
logger.info(
playwrightFailure !== null
? 'Recording partially succeeded, rendering in progress. Results available at:'
: 'Recording finished, rendering in progress. Results available at:'
)
logger.info(pc.cyan(projectUrl))
}
}
} catch (err) {
logger.warn('Failed to load config for upload:', err)
}
}
if (playwrightFailure !== null) {
throw playwrightFailure
}
})
program
+2 -2
View File
@@ -28,7 +28,7 @@ export default defineConfig({
use: {
videoOptions: {
resolution: '1080p', // '720p' | '1080p' | '4k' | { width, height }
fps: 30, // 24 | 30 | 60
fps: 60, // 24 | 30 | 60
quality: 'high', // 'low' | 'medium' | 'high'
},
trace: 'retain-on-failure', // 'on' | 'off' | 'retain-on-failure'
@@ -239,7 +239,7 @@ See [Writing Video Tests](./video-tests.md) and [Localization & Narrations](./lo
```ts
type RecordOptions = {
resolution?: Resolution // default: '1080p'
fps?: FPS // default: 30
fps?: FPS // default: 60
quality?: Quality // default: 'high'
}
```
+2
View File
@@ -98,6 +98,8 @@ Notes:
Records videos with ScreenCI by running local Playwright with `SCREENCI_RECORDING=true`, then uploads results if `SCREENCI_SECRET` is set. If the secret is missing, `record` prompts for login before recording begins.
By default, if some recording tests fail, ScreenCI still uploads the successful recordings. To opt out, set `record.upload: 'all-or-nothing'` in `screenci.config.ts`.
```bash
npx screenci record
npx screenci record --project=chromium
+37 -8
View File
@@ -28,13 +28,20 @@ export default defineConfig({
// Directory containing your *.video.ts files (default: './videos')
videoDir: './videos',
// Control what happens after partial recording failures.
// 'passed-only' uploads only successful recordings (default).
// 'all-or-nothing' skips all uploads if any recording test fails.
record: {
upload: 'passed-only', // 'passed-only' | 'all-or-nothing'
},
// Global timeout per test in ms (Playwright default applies if omitted)
timeout: 60_000,
use: {
videoOptions: {
resolution: '1080p', // '720p' | '1080p' | '4k' | { width, height }
fps: 30, // 24 | 30 | 60
fps: 60, // 24 | 30 | 60
quality: 'high', // 'low' | 'medium' | 'high'
},
@@ -85,15 +92,37 @@ video('Another 4K test', async ({ page }) => {
})
```
## Upload policy for `screenci record`
Use the top-level `record.upload` setting to control whether ScreenCI uploads recordings after a partial Playwright failure.
```ts
import { defineConfig } from 'screenci'
export default defineConfig({
record: {
upload: 'all-or-nothing',
},
})
```
Options:
- `passed-only` (default): if some recording tests fail, ScreenCI still uploads the successful recordings
- `all-or-nothing`: if any recording test fails, ScreenCI skips all uploads
`record.upload` only affects the `screenci record` CLI command. It does not change how Playwright runs the tests themselves.
## Default values
| Option | Default |
| ------------ | --------------------- |
| `videoDir` | `'./videos'` |
| `resolution` | `'1080p'` |
| `fps` | `30` |
| `quality` | `'high'` |
| `trace` | `'retain-on-failure'` |
| Option | Default |
| --------------- | --------------------- |
| `videoDir` | `'./videos'` |
| `record.upload` | `'passed-only'` |
| `resolution` | `'1080p'` |
| `fps` | `60` |
| `quality` | `'high'` |
| `trace` | `'retain-on-failure'` |
## What `defineConfig` enforces
+1 -1
View File
@@ -3,6 +3,7 @@ export type {
AspectRatio,
FPS,
Quality,
RecordUploadPolicy,
Trace,
RecordOptions,
RenderOptions,
@@ -60,7 +61,6 @@ export {
DEFAULT_ASPECT_RATIO,
DEFAULT_FPS,
DEFAULT_QUALITY,
DEFAULT_TRACE,
DEFAULT_VIDEO_DIR,
DEFAULT_VIDEO_OPTIONS,
} from './src/defaults.js'
+82
View File
@@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest'
import { defineConfig } from './config.js'
function getReporterNames(config: ReturnType<typeof defineConfig>): string[] {
return (config.reporter ?? []).map((reporter) => reporter[0])
}
describe('defineConfig', () => {
it('should default videoDir to ./videos', () => {
const config = defineConfig({ projectName: 'Test' })
@@ -62,6 +66,23 @@ describe('defineConfig', () => {
expect(config.use?.trace).toBe('retain-on-failure')
})
it('leaves root use.trace undefined outside recording when omitted', () => {
const config = defineConfig({ projectName: 'Test' })
expect(config.use?.trace).toBeUndefined()
})
it('preserves root use.trace outside recording when configured', () => {
const config = defineConfig({
projectName: 'Test',
use: {
trace: 'on-first-retry',
},
})
expect(config.use?.trace).toBe('on-first-retry')
})
it('should preserve project trace outside recording', () => {
const config = defineConfig({
projectName: 'Test',
@@ -78,6 +99,37 @@ describe('defineConfig', () => {
expect(config.projects?.[0].use?.trace).toBe('on')
})
it('leaves project use.trace undefined outside recording when omitted', () => {
const config = defineConfig({
projectName: 'Test',
projects: [
{
name: 'chromium',
use: {},
},
],
})
expect(config.projects?.[0].use?.trace).toBeUndefined()
})
it('defaults record.upload to passed-only', () => {
const config = defineConfig({ projectName: 'Test' })
expect(config.record.upload).toBe('passed-only')
})
it('preserves record.upload when configured', () => {
const config = defineConfig({
projectName: 'Test',
record: {
upload: 'all-or-nothing',
},
})
expect(config.record.upload).toBe('all-or-nothing')
})
it('should force retries to 0', () => {
const config = defineConfig({ projectName: 'Test' })
@@ -220,6 +272,36 @@ describe('defineConfig', () => {
}
})
it('filters out the html reporter while recording', () => {
process.env.SCREENCI_RECORDING = 'true'
try {
const config = defineConfig({
projectName: 'Test',
reporter: ['html', 'dot'],
})
expect(getReporterNames(config)).not.toContain('html')
expect(getReporterNames(config)).toContain('dot')
expect(
getReporterNames(config).some((name) => name.endsWith('reporter.ts')) ||
getReporterNames(config).some((name) => name.endsWith('reporter.js'))
).toBe(true)
} finally {
delete process.env.SCREENCI_RECORDING
}
})
it('keeps reporter configuration unchanged outside recording', () => {
const config = defineConfig({
projectName: 'Test',
reporter: ['html', 'dot'],
})
expect(getReporterNames(config)).toContain('html')
expect(getReporterNames(config)).toContain('dot')
})
it('should accept webServer option', () => {
const config = defineConfig({
projectName: 'Test',
+30 -15
View File
@@ -2,7 +2,7 @@ import type { ReporterDescription } from '@playwright/test'
import type { ScreenCIConfig, ExtendedScreenCIConfig } from './types.js'
import {
DEFAULT_VIDEO_DIR,
DEFAULT_TRACE,
DEFAULT_RECORD_UPLOAD_POLICY,
DEFAULT_TIMEOUT,
DEFAULT_ACTION_TIMEOUT,
DEFAULT_NAVIGATION_TIMEOUT,
@@ -23,6 +23,11 @@ const reporterPath = existsSync(reporterPathJs)
type ReporterConfig = string | ReporterDescription
function isHtmlReporter(reporter: ReporterConfig): boolean {
const reporterName = Array.isArray(reporter) ? reporter[0] : reporter
return reporterName === 'html'
}
/**
* Defines a screenci configuration file.
*
@@ -51,7 +56,7 @@ type ReporterConfig = string | ReporterDescription
* recordOptions: {
* aspectRatio: '16:9', // '16:9' | '9:16' | '1:1' | '4:3' | ...
* quality: '1080p', // '720p' | '1080p' | '1440p' | '2160p'
* fps: 30, // 24 | 30 | 60
* fps: 60, // 24 | 30 | 60
* },
* trace: 'retain-on-failure',
* },
@@ -62,15 +67,20 @@ type ReporterConfig = string | ReporterDescription
* @returns Extended Playwright configuration with screenci-managed test discovery
*/
export function defineConfig(config: ScreenCIConfig): ExtendedScreenCIConfig {
const isRecording = process.env.SCREENCI_RECORDING === 'true'
// Add the video name validator reporter if not already present
const existingReporters = config.reporter
? Array.isArray(config.reporter)
? config.reporter
: [config.reporter]
: ['list']
const filteredReporters = isRecording
? existingReporters.filter((reporter) => !isHtmlReporter(reporter))
: existingReporters
// Check if our validator is already added
const hasValidator = existingReporters.some((r: ReporterConfig) => {
const hasValidator = filteredReporters.some((r: ReporterConfig) => {
if (Array.isArray(r)) {
return r[0] === reporterPath || r[0]?.toString().endsWith('reporter.js')
}
@@ -78,7 +88,7 @@ export function defineConfig(config: ScreenCIConfig): ExtendedScreenCIConfig {
})
// Convert all reporters to tuple format for Playwright validation
const normalizedReporters = existingReporters.map((r: ReporterConfig) =>
const normalizedReporters = filteredReporters.map((r: ReporterConfig) =>
Array.isArray(r) ? r : [r]
)
@@ -149,32 +159,37 @@ export function defineConfig(config: ScreenCIConfig): ExtendedScreenCIConfig {
)
}
const { videoDir, ...rest } = config
const isRecording = process.env.SCREENCI_RECORDING === 'true'
const trace = isRecording ? 'off' : (rest.use?.trace ?? DEFAULT_TRACE)
const { videoDir, record, ...rest } = config
// recording does not need tracing, also it takes resources so that is why forced off
const trace = isRecording ? 'off' : rest.use?.trace
const projects = isRecording
? rest.projects?.map((project) => ({
...project,
use: {
...project.use,
trace,
trace: 'off' as const,
},
}))
: rest.projects
const use = {
...rest.use,
...(trace !== undefined ? { trace } : {}),
actionTimeout: rest.use?.actionTimeout ?? DEFAULT_ACTION_TIMEOUT,
navigationTimeout:
rest.use?.navigationTimeout ?? DEFAULT_NAVIGATION_TIMEOUT,
}
// Map videoDir to testDir and keep screenci-managed defaults in place.
return {
testDir: videoDir ?? DEFAULT_VIDEO_DIR,
testMatch: '**/*.video.?(c|m)[jt]s?(x)',
...rest,
reporter: reporters as ReporterDescription[],
use: {
...rest.use,
trace,
actionTimeout: rest.use?.actionTimeout ?? DEFAULT_ACTION_TIMEOUT,
navigationTimeout:
rest.use?.navigationTimeout ?? DEFAULT_NAVIGATION_TIMEOUT,
record: {
upload: record?.upload ?? DEFAULT_RECORD_UPLOAD_POLICY,
},
reporter: reporters as ReporterDescription[],
use,
...(projects ? { projects } : {}),
timeout: rest.timeout ?? DEFAULT_TIMEOUT,
retries: 0,
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_FPS, DEFAULT_VIDEO_OPTIONS } from './defaults.js'
describe('video defaults', () => {
it('defaults recording fps to 60', () => {
expect(DEFAULT_FPS).toBe(60)
expect(DEFAULT_VIDEO_OPTIONS.fps).toBe(60)
})
})
+7 -7
View File
@@ -3,7 +3,7 @@ import type {
AutoZoomOptions,
FPS,
Quality,
Trace,
RecordUploadPolicy,
RecordOptions,
} from './types.js'
@@ -35,18 +35,18 @@ export const DEFAULT_QUALITY: Quality = '1080p'
/**
* Default frames per second for video recording
*/
export const DEFAULT_FPS: FPS = 30
/**
* Default trace recording mode
*/
export const DEFAULT_TRACE: Trace = 'retain-on-failure'
export const DEFAULT_FPS: FPS = 60
/**
* Default directory for video files
*/
export const DEFAULT_VIDEO_DIR: string = './videos'
/**
* Default upload policy for `screenci record`
*/
export const DEFAULT_RECORD_UPLOAD_POLICY: RecordUploadPolicy = 'passed-only'
/**
* Default test timeout in milliseconds (30 minutes)
*/
+28 -3
View File
@@ -76,6 +76,16 @@ export type FPS = 24 | 30 | 60
*/
export type Trace = 'on' | 'off' | 'retain-on-failure'
/**
* Upload policy for `screenci record`.
*
* - `'passed-only'` uploads completed recordings even if other videos failed.
* - `'all-or-nothing'` skips all uploads when any video fails.
*
* @default 'passed-only'
*/
export type RecordUploadPolicy = 'passed-only' | 'all-or-nothing'
/**
* Rendering options passed as-is to `data.json`.
* Mirrors the `renderOptions` shape consumed by the rendering pipeline.
@@ -238,7 +248,7 @@ export type RecordOptions = {
/**
* Frames per second for video recording.
*
* @default 30
* @default 60
*/
fps?: FPS
}
@@ -757,6 +767,17 @@ export type ScreenCIConfig = Omit<
* Defaults to `'./videos'`.
*/
videoDir?: string
/**
* Options that only affect the `screenci record` command.
*/
record?: {
/**
* Controls whether recordings are uploaded after partial Playwright failures.
*
* @default 'passed-only'
*/
upload?: RecordUploadPolicy
}
/**
* Starts and reuses a development server through Playwright before running videos.
*
@@ -805,5 +826,9 @@ export type ScreenCIConfig = Omit<
})[]
}
export type ExtendedScreenCIConfig = ScreenCIConfig &
Pick<PlaywrightTestConfig, 'retries' | 'testDir' | 'testMatch'>
export type ExtendedScreenCIConfig = Omit<ScreenCIConfig, 'record'> &
Pick<PlaywrightTestConfig, 'retries' | 'testDir' | 'testMatch'> & {
record: {
upload: RecordUploadPolicy
}
}
+4 -3
View File
@@ -387,8 +387,9 @@ const _videoBase = base.extend<
await page.close()
// Write recorded events next to the video
await recorder.writeToFile(videoDir, testInfo.title)
if (testInfo.status === 'passed') {
await recorder.writeToFile(videoDir, testInfo.title)
}
}
},
})
@@ -435,7 +436,7 @@ interface VideoCallSignatures {
* video('Product demo', async ({ page }) => {
* await page.goto('https://example.com')
* await page.click('text=Get Started')
* // Video recorded at 16:9 1080p, 30fps (defaults)
* // Video recorded at 16:9 1080p, 60fps (defaults)
* })
* ```
*
@@ -0,0 +1,6 @@
export default {
projectName: 'Test Project',
record: {
upload: 'all-or-nothing',
},
}
@@ -0,0 +1,6 @@
export default {
projectName: 'Test Project',
record: {
upload: 'all-or-nothing',
},
}
+3
View File
@@ -0,0 +1,3 @@
export default {
projectName: 'Test Project',
}