mirror of
https://github.com/screenci/screenci.git
synced 2026-09-19 08:57:46 +08:00
feat(screenci): add all-or-nothing recording config
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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'
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
projectName: 'Test Project',
|
||||
}
|
||||
Reference in New Issue
Block a user