diff --git a/README.md b/README.md index 82d2ce7..fd31d6d 100644 --- a/README.md +++ b/README.md @@ -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 }, }, }) diff --git a/cli.spec.ts b/cli.spec.ts index a48890d..c63f5ff 100644 --- a/cli.spec.ts +++ b/cli.spec.ts @@ -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) diff --git a/cli.ts b/cli.ts index 9e20341..0a60461 100644 --- a/cli.ts +++ b/cli.ts @@ -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 { + 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 & { 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 & { + 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 diff --git a/docs/api.md b/docs/api.md index 396018a..627b927 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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' } ``` diff --git a/docs/cli.md b/docs/cli.md index e2baf41..6e18596 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 diff --git a/docs/configuration.md b/docs/configuration.md index df9c41b..ce55de4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 diff --git a/index.ts b/index.ts index 371238a..9c8a6b3 100644 --- a/index.ts +++ b/index.ts @@ -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' diff --git a/src/config.spec.ts b/src/config.spec.ts index b2e1cd8..fb00706 100644 --- a/src/config.spec.ts +++ b/src/config.spec.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from 'vitest' import { defineConfig } from './config.js' +function getReporterNames(config: ReturnType): 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', diff --git a/src/config.ts b/src/config.ts index 000a3d6..cf87cfd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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, diff --git a/src/defaults.spec.ts b/src/defaults.spec.ts new file mode 100644 index 0000000..a82a4ec --- /dev/null +++ b/src/defaults.spec.ts @@ -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) + }) +}) diff --git a/src/defaults.ts b/src/defaults.ts index aa750a7..c28749d 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -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) */ diff --git a/src/types.ts b/src/types.ts index 1102535..90a0d99 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 +export type ExtendedScreenCIConfig = Omit & + Pick & { + record: { + upload: RecordUploadPolicy + } + } diff --git a/src/video.ts b/src/video.ts index c74ed17..fa8ac22 100644 --- a/src/video.ts +++ b/src/video.ts @@ -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) * }) * ``` * diff --git a/test-fixtures/record-all-or-nothing.config.ts b/test-fixtures/record-all-or-nothing.config.ts new file mode 100644 index 0000000..2fbfbb9 --- /dev/null +++ b/test-fixtures/record-all-or-nothing.config.ts @@ -0,0 +1,6 @@ +export default { + projectName: 'Test Project', + record: { + upload: 'all-or-nothing', + }, +} diff --git a/test-fixtures/record-upload-all-or-nothing.config.ts b/test-fixtures/record-upload-all-or-nothing.config.ts new file mode 100644 index 0000000..2fbfbb9 --- /dev/null +++ b/test-fixtures/record-upload-all-or-nothing.config.ts @@ -0,0 +1,6 @@ +export default { + projectName: 'Test Project', + record: { + upload: 'all-or-nothing', + }, +} diff --git a/test-fixtures/record-upload.config.ts b/test-fixtures/record-upload.config.ts new file mode 100644 index 0000000..39c5870 --- /dev/null +++ b/test-fixtures/record-upload.config.ts @@ -0,0 +1,3 @@ +export default { + projectName: 'Test Project', +}