editor: rename record command to export, edit specs, export run updates

This commit is contained in:
Olli Paloviita
2026-08-12 19:59:23 +03:00
parent 7fd89186be
commit 96fddbaa6c
54 changed files with 2137 additions and 729 deletions
+4 -3
View File
@@ -3,7 +3,7 @@
Your UI changed. Your demo videos didn't. screenci fixes that.
Record product walkthroughs as code. When the UI ships, run
`npx screenci record` and your videos regenerate. You keep the Playwright
`npx screenci export` and your videos regenerate. You keep the Playwright
mental model, but the output is a maintainable product video instead of a
test report.
@@ -21,11 +21,12 @@ This scaffolds a self-contained `screenci/` directory with its own
dependencies and installs Chromium. The directory is isolated from the
surrounding workspace, which keeps installation reliable inside monorepos.
Then write a video, run it locally, and record the final output:
Then write a video, run it locally, refine it, and export the final output:
```bash
npx screenci test # author the video
npx screenci record # render and upload the final video
npx screenci edit # record it and open the web editor
npx screenci export # render and download the finished video
```
Full docs:
+1 -1
View File
@@ -376,7 +376,7 @@ describe('CLI', () => {
await expect(main()).rejects.toThrow('process.exit called')
expect(loggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('record, dev, test')
expect.stringContaining('edit, export, test')
)
// The browser sign-in `login` command is gone from the OTP-based flow, so
// it must not be advertised as a top-level command.
+37
View File
@@ -218,6 +218,43 @@ describe('runDevListenLoop', () => {
])
})
it('logs a render-time edit as needing no re-record, a record-affecting one without the note', async () => {
const controller = { stopped: false }
const applyCodegen = vi.fn(async () => {})
const deps = makeDeps({ applyCodegen })
const renderOptionsRequest: DevCodegenRequest = {
requestId: 'cgr_2',
videoName: 'Intro video',
editId: 'options:renderOptions',
editJson: '{"type":"optionsEdit"}',
requiresRecord: false,
}
deps.fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/cli/dev/poll')) {
if (deps.fetchMock.mock.calls.length === 1) {
return jsonResponse({
trigger: null,
codegenRequests: [codegenRequest, renderOptionsRequest],
})
}
controller.stopped = true
return jsonResponse({ trigger: null })
}
return jsonResponse({ ok: true })
})
await runDevListenLoop(config, deps, 'lst_1', controller)
const infoLines = (deps.logger.info as ReturnType<typeof vi.fn>).mock.calls
.map(([line]) => line as string)
.filter((line) => line.startsWith('Applied'))
expect(infoLines).toHaveLength(2)
expect(infoLines[0]).not.toContain('no re-record needed')
expect(infoLines[1]).toContain(
'Applies at render time, no re-record needed.'
)
})
it('reports a stale-key edit as orphaned (soft skip), not failed', async () => {
const controller = { stopped: false }
const applyCodegen = vi.fn(async () => ({ outcome: 'orphaned' as const }))
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { resolveSingleEditVideo } from './cli'
const suggest = (name: string) => `screenci edit "${name}"`
describe('resolveSingleEditVideo', () => {
it('resolves the only video of a project without a pattern', () => {
expect(resolveSingleEditVideo(['Login'], undefined, suggest)).toEqual({
ok: true,
videoName: 'Login',
})
})
it('resolves a pattern matching exactly one video', () => {
const result = resolveSingleEditVideo(['Login', 'Signup'], 'Sign', suggest)
expect(result).toEqual({ ok: true, videoName: 'Signup' })
})
it('errors with the available titles when nothing matches', () => {
const result = resolveSingleEditVideo(['Login', 'Signup'], 'Nope', suggest)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.message).toContain('No video matches "Nope"')
expect(result.message).toContain('- Login')
expect(result.message).toContain('- Signup')
}
})
it('errors listing the matches when the pattern matches several videos', () => {
const result = resolveSingleEditVideo(
['Login flow', 'Login error', 'Signup'],
'Login',
suggest
)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.message).toContain('one video at a time')
expect(result.message).toContain('- Login flow')
expect(result.message).toContain('- Login error')
expect(result.message).not.toContain('- Signup')
expect(result.message).toContain('screenci edit "Login flow"')
}
})
it('errors asking to pick when the project has several videos and no pattern', () => {
const result = resolveSingleEditVideo(['A', 'B'], undefined, suggest)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.message).toContain('one video at a time')
expect(result.message).toContain('screenci edit "A"')
}
})
it('errors when the project has no videos at all', () => {
const result = resolveSingleEditVideo([], 'Login', suggest)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.message).toContain('No videos found')
}
})
})
+103 -21
View File
@@ -575,14 +575,20 @@ describe('CLI', () => {
})
})
describe('record command', () => {
describe('export command', () => {
beforeEach(() => {
process.env.SCREENCI_SECRET = 'test-secret'
})
afterEach(() => {
// Export sets process.exitCode on failed/empty runs; never leak it into
// the test runner process.
process.exitCode = undefined
})
it('runs Playwright and does not exit when SCREENCI_SECRET is missing', async () => {
delete process.env.SCREENCI_SECRET
process.argv = ['node', 'cli.js', 'record']
process.argv = ['node', 'cli.js', 'export']
mockSpawn.mockImplementation(() => {
process.nextTick(() => mockChildProcess.emit('close', 0))
return mockChildProcess as unknown as ChildProcess
@@ -601,7 +607,7 @@ describe('CLI', () => {
it('loads SCREENCI_SECRET from the project .env when envFile is not configured', async () => {
delete process.env.SCREENCI_SECRET
process.argv = ['node', 'cli.js', 'record']
process.argv = ['node', 'cli.js', 'export']
if (loadEnvFileSpy) {
loadEnvFileSpy.mockImplementation((path?: string | URL) => {
if (String(path) === `${process.cwd()}/.env`) {
@@ -632,7 +638,7 @@ describe('CLI', () => {
})
it('should run Playwright locally for record command', async () => {
process.argv = ['node', 'cli.js', 'record']
process.argv = ['node', 'cli.js', 'export']
process.env.VITE_APP_BASE_URL = 'https://example.com'
mockSpawn.mockImplementation(
(
@@ -671,7 +677,7 @@ describe('CLI', () => {
process.argv = [
'node',
'cli.js',
'record',
'export',
'--config',
'test-fixtures/record-upload.config.ts',
'--grep',
@@ -761,7 +767,7 @@ describe('CLI', () => {
})
it('should only log the config path in verbose mode', async () => {
process.argv = ['node', 'cli.js', 'record', '--verbose']
process.argv = ['node', 'cli.js', 'export', '--verbose']
process.env.VITE_APP_BASE_URL = 'https://example.com'
mockReadFile.mockImplementation(async (path: string | URL) => {
if (String(path).endsWith('screenci.config.ts')) {
@@ -1000,7 +1006,7 @@ describe('CLI', () => {
process.argv = [
'node',
'cli.js',
'record',
'export',
'--config',
'test-fixtures/record-upload.config.ts',
]
@@ -1038,6 +1044,44 @@ describe('CLI', () => {
text: vi.fn().mockResolvedValue(''),
}
}
if (url.includes('/cli/info')) {
return {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({
projectName: 'Test Project',
projectId: 'project_123',
videos: {
Demo: {
videoId: 'video_123',
languages: {
en: {
latestRecord: {
status: 'finished',
download: {
video:
'http://localhost:8787/cli/download/video_123/records/r1/en/video',
screenshot:
'http://localhost:8787/cli/download/video_123/records/r1/en/screenshot',
},
},
},
},
},
},
}),
text: vi.fn().mockResolvedValue(''),
}
}
if (url.includes('/cli/download/')) {
return {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({}),
text: vi.fn().mockResolvedValue(''),
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(4)),
}
}
return {
ok: true,
status: 200,
@@ -1058,7 +1102,7 @@ describe('CLI', () => {
stripVTControlCharacters(String(call[0]))
)
expect(messages).toContain(
'Recording finished, rendering in progress. Results available at:'
'Recording finished, export render in progress. Results available at:'
)
expect(
messages.some((message) => message.includes('ScreenCI watermark'))
@@ -1074,7 +1118,7 @@ describe('CLI', () => {
process.argv = [
'node',
'cli.js',
'record',
'export',
'--config',
'test-fixtures/record-upload.config.ts',
]
@@ -1148,7 +1192,7 @@ describe('CLI', () => {
return (
typeof parsed.token === 'string' &&
typeof parsed.recordUrl === 'string' &&
parsed.recordUrl.startsWith('http://localhost:5173/record/')
parsed.recordUrl.startsWith('http://localhost:5173/export/')
)
})
).toBe(true)
@@ -1158,7 +1202,7 @@ describe('CLI', () => {
process.argv = [
'node',
'cli.js',
'record',
'export',
'--config',
'test-fixtures/record-upload.config.ts',
]
@@ -1196,6 +1240,44 @@ describe('CLI', () => {
text: vi.fn().mockResolvedValue(''),
}
}
if (url.includes('/cli/info')) {
return {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({
projectName: 'Test Project',
projectId: 'project_123',
videos: {
Demo: {
videoId: 'video_123',
languages: {
en: {
latestRecord: {
status: 'finished',
download: {
video:
'http://localhost:8787/cli/download/video_123/records/r1/en/video',
screenshot:
'http://localhost:8787/cli/download/video_123/records/r1/en/screenshot',
},
},
},
},
},
},
}),
text: vi.fn().mockResolvedValue(''),
}
}
if (url.includes('/cli/download/')) {
return {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({}),
text: vi.fn().mockResolvedValue(''),
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(4)),
}
}
return {
ok: true,
status: 200,
@@ -1216,7 +1298,7 @@ describe('CLI', () => {
stripVTControlCharacters(String(call[0]))
)
expect(messages).toContain(
'Recording finished, rendering in progress. Results available at:'
'Recording finished, export render in progress. Results available at:'
)
expect(messages.some((message) => message.includes('/select-plan'))).toBe(
false
@@ -1509,7 +1591,7 @@ describe('CLI', () => {
process.argv = [
'node',
'cli.js',
'record',
'export',
'--config',
'test-fixtures/record-upload.config.ts',
]
@@ -1593,7 +1675,7 @@ describe('CLI', () => {
process.argv = [
'node',
'cli.js',
'record',
'export',
'--config',
'test-fixtures/record-upload-all-or-nothing.config.ts',
]
@@ -2415,7 +2497,7 @@ describe('CLI', () => {
process.argv = [
'node',
'cli.js',
'record',
'export',
'--config',
'test-fixtures/record-upload.config.ts',
]
@@ -3007,7 +3089,7 @@ describe('CLI', () => {
process.argv = [
'node',
'cli.js',
'record',
'export',
'--config',
'test-fixtures/record-upload.config.ts',
]
@@ -3115,7 +3197,7 @@ describe('CLI', () => {
process.argv = [
'node',
'cli.js',
'record',
'export',
'--config',
'test-fixtures/record-upload.config.ts',
]
@@ -3184,7 +3266,7 @@ describe('CLI', () => {
process.argv = [
'node',
'cli.js',
'record',
'export',
'--config',
'test-fixtures/record-upload-all-or-nothing.config.js',
]
@@ -3210,7 +3292,7 @@ describe('CLI', () => {
describe('--remote', () => {
it('dispatches the workflow and does not record locally', async () => {
process.argv = ['node', 'cli.js', 'record', '--remote']
process.argv = ['node', 'cli.js', 'export', '--remote']
const { main } = await import('./cli')
await main()
@@ -3244,7 +3326,7 @@ describe('CLI', () => {
process.argv = [
'node',
'cli.js',
'record',
'export',
'--remote',
'--grep',
'Onboarding',
@@ -3266,7 +3348,7 @@ describe('CLI', () => {
})
it('throws when the backend rejects the trigger', async () => {
process.argv = ['node', 'cli.js', 'record', '--remote']
process.argv = ['node', 'cli.js', 'export', '--remote']
mockFetch.mockResolvedValue({
ok: false,
status: 400,
+359
View File
@@ -0,0 +1,359 @@
import { describe, expect, it, vi } from 'vitest'
import type { RecordingData } from './src/recordingData'
import { computeSourceHash } from './src/recordingFreshness'
import {
downloadExportOutputs,
exportExitCode,
exportFileName,
partitionExportVideos,
pollExportRenders,
type ExportInfoResponse,
type ExportRenderResult,
} from './src/exportRun'
function keptData(params: {
videoName: string
sourceFilePath?: string
sourceHash?: string
}): RecordingData {
return {
events: [],
metadata: {
videoName: params.videoName,
...(params.sourceFilePath !== undefined && {
sourceFilePath: params.sourceFilePath,
}),
...(params.sourceHash !== undefined && {
sourceHash: params.sourceHash,
}),
},
} as unknown as RecordingData
}
describe('partitionExportVideos', () => {
const hash = computeSourceHash('source-v1')
it('marks a video fresh when its kept upload matches the current source', async () => {
const partition = await partitionExportVideos({
requestedNames: ['Login'],
keptByVideoName: new Map([
[
'Login',
keptData({
videoName: 'Login',
sourceFilePath: '/p/login.screenci.ts',
sourceHash: hash,
}),
],
]),
uploadedVideos: { Login: { sourceHash: hash } },
hashSource: async () => hash,
force: false,
})
expect(partition).toEqual({ fresh: ['Login'], stale: [] })
})
it('marks a video stale when the source changed since the upload', async () => {
const partition = await partitionExportVideos({
requestedNames: ['Login'],
keptByVideoName: new Map([
[
'Login',
keptData({
videoName: 'Login',
sourceFilePath: '/p/login.screenci.ts',
sourceHash: hash,
}),
],
]),
uploadedVideos: { Login: { sourceHash: hash } },
// The file on disk changed after the upload.
hashSource: async () => computeSourceHash('source-v2'),
force: false,
})
expect(partition).toEqual({ fresh: [], stale: ['Login'] })
})
it('marks a video stale when it was never uploaded or has no kept data', async () => {
const partition = await partitionExportVideos({
requestedNames: ['Login', 'Signup'],
keptByVideoName: new Map([
[
'Login',
keptData({
videoName: 'Login',
sourceFilePath: '/p/login.screenci.ts',
sourceHash: hash,
}),
],
]),
// Login has kept data but no recorded upload; Signup has neither.
uploadedVideos: {},
hashSource: async () => hash,
force: false,
})
expect(partition).toEqual({ fresh: [], stale: ['Login', 'Signup'] })
})
it('marks everything stale with force', async () => {
const partition = await partitionExportVideos({
requestedNames: ['Login'],
keptByVideoName: new Map([
[
'Login',
keptData({
videoName: 'Login',
sourceFilePath: '/p/login.screenci.ts',
sourceHash: hash,
}),
],
]),
uploadedVideos: { Login: { sourceHash: hash } },
hashSource: async () => hash,
force: true,
})
expect(partition).toEqual({ fresh: [], stale: ['Login'] })
})
})
function infoResponse(
entries: Record<
string,
Record<
string,
{
status: 'finished' | 'rendering' | 'failed'
failureMessage?: string
}
>
>
): ExportInfoResponse {
return {
videos: Object.fromEntries(
Object.entries(entries).map(([videoName, languages]) => [
videoName,
{
videoId: `vid-${videoName}`,
languages: Object.fromEntries(
Object.entries(languages).map(([language, latest]) => [
language,
{
latestRecord: {
...latest,
...(latest.status === 'finished' && {
download: {
video: `https://api/dl/${videoName}/${language}/video`,
screenshot: `https://api/dl/${videoName}/${language}/screenshot`,
},
}),
},
},
])
),
},
])
),
}
}
describe('pollExportRenders', () => {
it('polls until every language reports a terminal status', async () => {
const responses = [
infoResponse({ Login: { en: { status: 'rendering' } } }),
infoResponse({
Login: { en: { status: 'finished' }, fi: { status: 'finished' } },
}),
]
let call = 0
const sleep = vi.fn(async () => {})
const results = await pollExportRenders({
targets: [{ recordId: 'rec_1', videoNames: ['Login'] }],
intervalMs: 5000,
maxAttempts: 10,
deps: {
fetchInfo: async () => responses[Math.min(call++, 1)]!,
sleep,
log: () => {},
},
})
expect(sleep).toHaveBeenCalledWith(5000)
expect(
results.map((r) => `${r.videoName}/${r.language}/${r.status}`)
).toEqual(['Login/en/finished', 'Login/fi/finished'])
expect(results[0]?.downloadUrl).toBe('https://api/dl/Login/en/video')
})
it('reports failures with their message', async () => {
const results = await pollExportRenders({
targets: [{ recordId: 'rec_1', videoNames: ['Login'] }],
intervalMs: 1,
maxAttempts: 2,
deps: {
fetchInfo: async () =>
infoResponse({
Login: {
en: { status: 'failed', failureMessage: 'Render exploded' },
},
}),
sleep: async () => {},
log: () => {},
},
})
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
status: 'failed',
failureMessage: 'Render exploded',
})
})
it('restricts the watched languages to the requested filter', async () => {
const results = await pollExportRenders({
targets: [{ recordId: 'rec_1', videoNames: ['Login'] }],
languages: ['fi'],
intervalMs: 1,
maxAttempts: 3,
deps: {
fetchInfo: async () =>
infoResponse({
Login: { en: { status: 'rendering' }, fi: { status: 'finished' } },
}),
sleep: async () => {},
log: () => {},
},
})
expect(results).toEqual([
expect.objectContaining({ language: 'fi', status: 'finished' }),
])
})
it('times out a video that never reports a render', async () => {
const results = await pollExportRenders({
targets: [{ recordId: 'rec_1', videoNames: ['Login'] }],
intervalMs: 1,
maxAttempts: 3,
deps: {
fetchInfo: async () => ({ videos: {} }),
sleep: async () => {},
log: () => {},
},
})
expect(results).toEqual([
expect.objectContaining({ videoName: 'Login', status: 'timeout' }),
])
})
})
describe('exportFileName', () => {
it('sanitizes the title and appends language and extension', () => {
expect(exportFileName('Login flow', 'en', 'mp4')).toBe(
`${'Login flow'.replace(/[^a-zA-Z0-9 _-]/g, '')}.en.mp4`.replace(/^/, '')
)
// Path separators never leak into the file name.
expect(exportFileName('a/b', 'en', 'mp4')).not.toContain('/')
})
})
function finishedResult(
videoName: string,
language = 'en'
): ExportRenderResult {
return {
videoName,
language,
status: 'finished',
downloadUrl: `https://api/dl/${videoName}/${language}/video`,
screenshotUrl: `https://api/dl/${videoName}/${language}/screenshot`,
}
}
describe('downloadExportOutputs', () => {
it('downloads each finished render into the output directory', async () => {
const written = new Map<string, Uint8Array>()
const downloads = await downloadExportOutputs({
results: [finishedResult('Login'), finishedResult('Signup')],
outDir: '/exports',
deps: {
fetchFn: async () => ({
ok: true,
status: 200,
arrayBuffer: async () => new TextEncoder().encode('bytes').buffer,
}),
mkdir: async () => {},
writeFile: async (path, data) => {
written.set(path, data)
},
},
})
expect(downloads.map((d) => d.filePath)).toEqual([
expect.stringContaining('Login.en.mp4'),
expect.stringContaining('Signup.en.mp4'),
])
expect(written.size).toBe(2)
})
it('falls back to the screenshot URL when the video URL 404s', async () => {
const downloads = await downloadExportOutputs({
results: [finishedResult('Shot')],
outDir: '/exports',
deps: {
fetchFn: async (url) =>
url.endsWith('/video')
? {
ok: false,
status: 404,
arrayBuffer: async () => new ArrayBuffer(0),
}
: {
ok: true,
status: 200,
arrayBuffer: async () => new ArrayBuffer(1),
},
mkdir: async () => {},
writeFile: async () => {},
},
})
expect(downloads[0]?.filePath).toContain('Shot.en.png')
})
it('reports a failed download without throwing', async () => {
const downloads = await downloadExportOutputs({
results: [finishedResult('Login')],
outDir: '/exports',
deps: {
fetchFn: async () => ({
ok: false,
status: 500,
arrayBuffer: async () => new ArrayBuffer(0),
}),
mkdir: async () => {},
writeFile: async () => {},
},
})
expect(downloads[0]).toMatchObject({ filePath: null })
expect(downloads[0]?.error).toContain('500')
})
})
describe('exportExitCode', () => {
it('is 0 only when everything finished and downloaded', () => {
const results = [finishedResult('Login')]
expect(
exportExitCode(results, [
{ videoName: 'Login', language: 'en', filePath: '/e/Login.en.mp4' },
])
).toBe(0)
})
it('is 1 on failure, timeout, empty results, or a failed download', () => {
expect(exportExitCode([], [])).toBe(1)
expect(
exportExitCode([{ ...finishedResult('Login'), status: 'failed' }], [])
).toBe(1)
expect(
exportExitCode(
[finishedResult('Login')],
[{ videoName: 'Login', language: 'en', filePath: null }]
)
).toBe(1)
})
})
+12 -50
View File
@@ -514,7 +514,8 @@ describe('CLI', () => {
expect(islandPkg['name']).toBe('my-project')
expect(islandPkg['scripts']).toMatchObject({
test: 'screenci test',
record: 'screenci record',
edit: 'screenci edit',
export: 'screenci export',
})
expect(islandPkg['scripts']).not.toHaveProperty('screenci')
// Init enables codegen formatting: a user-editable .prettierrc gates it.
@@ -872,7 +873,7 @@ describe('CLI', () => {
expect(workflowCall?.[1]).toContain(
'run: pnpm exec playwright install --only-shell chromium'
)
expect(workflowCall?.[1]).toContain('pnpm exec screenci record')
expect(workflowCall?.[1]).toContain('pnpm exec screenci export')
// The commented local-app build hint tracks the detected package manager.
expect(workflowCall?.[1]).toContain(
'# run: pnpm install --frozen-lockfile'
@@ -881,7 +882,7 @@ describe('CLI', () => {
// Targeted recordings: optional `grep` input forwarded to record.
expect(workflowCall?.[1]).toContain('SCREENCI_GREP: ${{ inputs.grep }}')
expect(workflowCall?.[1]).toContain(
'pnpm exec screenci record --grep "$SCREENCI_GREP"'
'pnpm exec screenci export --grep "$SCREENCI_GREP"'
)
expect(workflowCall?.[1]).toMatch(/workflow_dispatch:\s*\n\s*inputs:/)
})
@@ -1043,9 +1044,9 @@ describe('CLI', () => {
expect(workflowCall?.[1]).toContain(
'run: yarn playwright install --only-shell chromium'
)
expect(workflowCall?.[1]).toContain('yarn screenci record')
expect(workflowCall?.[1]).toContain('yarn screenci export')
expect(workflowCall?.[1]).toContain(
'yarn screenci record --grep "$SCREENCI_GREP"'
'yarn screenci export --grep "$SCREENCI_GREP"'
)
})
@@ -2302,13 +2303,13 @@ describe('CLI', () => {
})
})
describe('disallowed flags validation', () => {
describe('playwright flag pass-through (test command)', () => {
beforeEach(() => {
process.env.SCREENCI_SECRET = 'test-secret'
})
it('should allow --fully-parallel to pass through', async () => {
process.argv = ['node', 'cli.js', 'record', '--fully-parallel']
process.argv = ['node', 'cli.js', 'test', '--fully-parallel']
mockSpawn.mockImplementation(
(
_command: string,
@@ -2327,7 +2328,7 @@ describe('CLI', () => {
})
it('should allow --workers to pass through', async () => {
process.argv = ['node', 'cli.js', 'record', '--workers', '4']
process.argv = ['node', 'cli.js', 'test', '--workers', '4']
mockSpawn.mockImplementation(
(
_command: string,
@@ -2347,7 +2348,7 @@ describe('CLI', () => {
})
it('should allow --workers=N to pass through', async () => {
process.argv = ['node', 'cli.js', 'record', '--workers=4']
process.argv = ['node', 'cli.js', 'test', '--workers=4']
mockSpawn.mockImplementation(
(
_command: string,
@@ -2366,7 +2367,7 @@ describe('CLI', () => {
})
it('should allow -j to pass through', async () => {
process.argv = ['node', 'cli.js', 'record', '-j', '4']
process.argv = ['node', 'cli.js', 'test', '-j', '4']
mockSpawn.mockImplementation(
(
_command: string,
@@ -2386,7 +2387,7 @@ describe('CLI', () => {
})
it('should allow -j=N to pass through', async () => {
process.argv = ['node', 'cli.js', 'record', '-j=4']
process.argv = ['node', 'cli.js', 'test', '-j=4']
mockSpawn.mockImplementation(
(
_command: string,
@@ -2403,44 +2404,5 @@ describe('CLI', () => {
await main()
})
it('should throw error when --retries is provided', async () => {
process.argv = ['node', 'cli.js', 'record', '--retries', '2']
const { main } = await import('./cli')
await expect(main()).rejects.toThrow(
'Flag "--retries" is not supported by screenci'
)
})
it('should throw error when --retries=N is provided', async () => {
process.argv = ['node', 'cli.js', 'record', '--retries=2']
const { main } = await import('./cli')
await expect(main()).rejects.toThrow(
'Flag "--retries=2" is not supported by screenci'
)
})
it('should reject retries even when other parallel flags are present', async () => {
process.argv = [
'node',
'cli.js',
'record',
'--workers',
'4',
'--fully-parallel',
'--retries',
'2',
]
const { main } = await import('./cli')
await expect(main()).rejects.toThrow(
'Flag "--retries" is not supported by screenci'
)
})
})
})
+40 -10
View File
@@ -559,8 +559,8 @@ describe('CLI', () => {
stdoutSpy.mockRestore()
})
it('should show command help with record --help', async () => {
process.argv = ['node', 'cli.js', 'record', '--help']
it('should show command help with export --help', async () => {
process.argv = ['node', 'cli.js', 'export', '--help']
const stdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true)
@@ -571,7 +571,7 @@ describe('CLI', () => {
expect(stdoutSpy).toHaveBeenCalled()
expect(
stdoutSpy.mock.calls.some((call) =>
String(call[0]).includes('Usage: screenci record')
String(call[0]).includes('Usage: screenci export')
)
).toBe(true)
expect(mockSpawn).not.toHaveBeenCalled()
@@ -581,7 +581,7 @@ describe('CLI', () => {
it('should exit if default config not found', async () => {
process.env.SCREENCI_RECORDING = 'true'
process.argv = ['node', 'cli.js', 'record']
process.argv = ['node', 'cli.js', 'export']
mockExistsSync.mockReturnValue(false)
const { main } = await import('./cli')
@@ -599,7 +599,7 @@ describe('CLI', () => {
process.argv = [
'node',
'cli.js',
'record',
'export',
'--config',
'missing.config.ts',
]
@@ -618,7 +618,7 @@ describe('CLI', () => {
})
it('should exit if --config flag provided without value', async () => {
process.argv = ['node', 'cli.js', 'record', '--config']
process.argv = ['node', 'cli.js', 'export', '--config']
const { main } = await import('./cli')
@@ -631,7 +631,7 @@ describe('CLI', () => {
})
it('should exit if -c flag provided without value', async () => {
process.argv = ['node', 'cli.js', 'record', '-c']
process.argv = ['node', 'cli.js', 'export', '-c']
const { main } = await import('./cli')
@@ -643,8 +643,8 @@ describe('CLI', () => {
expect(processExitSpy).toHaveBeenCalledWith(1)
})
it('logs mock-record troubleshooting help when record fails', async () => {
process.argv = ['node', 'cli.js', 'record']
it('logs mock-record troubleshooting help when the export record pass fails', async () => {
process.argv = ['node', 'cli.js', 'export']
process.env.SCREENCI_SECRET = 'test-secret'
mockSpawn.mockImplementation(() => {
process.nextTick(() => mockChildProcess.emit('close', 1))
@@ -679,7 +679,7 @@ describe('CLI', () => {
})
it('surfaces the first Playwright discovery error and snippet instead of raw JSON', async () => {
process.argv = ['node', 'cli.js', 'record']
process.argv = ['node', 'cli.js', 'export']
process.env.SCREENCI_SECRET = 'test-secret'
mockSpawn.mockImplementation((_command: string, args: string[]) => {
if (args.includes('--list')) {
@@ -1095,4 +1095,34 @@ describe('CLI', () => {
expect(parseRecordCliArgs(['--grep', 'x']).languages).toBeUndefined()
})
})
describe('formatRecordResultMessage', () => {
it('reports a preview refresh for a plain record', async () => {
const { formatRecordResultMessage } = await import('./cli')
expect(
formatRecordResultMessage({ exported: false, partial: false })
).toBe('Recording finished, live preview updated. Edit and export at:')
})
it('reports a render only for an export run', async () => {
const { formatRecordResultMessage } = await import('./cli')
expect(
formatRecordResultMessage({ exported: true, partial: false })
).toBe(
'Recording finished, export render in progress. Results available at:'
)
})
it('keeps the partial prefix in both modes', async () => {
const { formatRecordResultMessage } = await import('./cli')
expect(
formatRecordResultMessage({ exported: false, partial: true })
).toBe(
'Recording partially succeeded, live preview updated. Edit and export at:'
)
expect(formatRecordResultMessage({ exported: true, partial: true })).toBe(
'Recording partially succeeded, export render in progress. Results available at:'
)
})
})
})
+2 -2
View File
@@ -686,7 +686,7 @@ describe('CLI', () => {
await main()
expect(loggerInfoSpy).toHaveBeenCalledWith(
`Tests passed. Run ${pc.cyan('npx screenci record')} to render the videos.`
`Tests passed. Run ${pc.cyan('npx screenci edit')} to record and edit a video, or ${pc.cyan('npx screenci export')} to export finished videos.`
)
})
@@ -702,7 +702,7 @@ describe('CLI', () => {
await main()
expect(loggerInfoSpy).toHaveBeenCalledWith(
`Tests passed. Run ${pc.cyan('pnpm exec screenci record')} to render the videos.`
`Tests passed. Run ${pc.cyan('pnpm exec screenci edit')} to record and edit a video, or ${pc.cyan('pnpm exec screenci export')} to export finished videos.`
)
})
+698 -137
View File
@@ -90,8 +90,20 @@ import {
import { entriesFromRecordingData } from './src/editableSnapshot.js'
import {
LAST_DATA_FILE,
hashSourceFile,
readKeptRecordingData,
} from './src/recordingFreshness.js'
import type { RecordingData as KeptRecordingData } from './src/recordingData.js'
import {
downloadExportOutputs,
exportExitCode,
partitionExportVideos,
pollExportRenders,
type ExportInfoResponse,
type ExportPollTarget,
type ExportRenderResult,
type UploadedVideoState,
} from './src/exportRun.js'
import { loadTypescript } from './src/codemod.js'
import {
planDuplicateEditIdFixes,
@@ -338,7 +350,7 @@ function logScreenCISecretGuide(): void {
}
function getSuggestedScreenciCommand(
command: 'record' | 'test',
command: 'edit' | 'export' | 'test',
flags = ''
): string {
const suffix = flags ? ` ${flags}` : ''
@@ -641,6 +653,20 @@ export function formatStudioUrl(
return `${appUrl}/project/${projectId}/video/${videoId}?editor`
}
export function formatRecordResultMessage(options: {
exported: boolean
partial: boolean
}): string {
const prefix = options.partial
? 'Recording partially succeeded'
: 'Recording finished'
// Preview-first: a plain `record` only refreshes the live preview; a render
// is dispatched only when this run was an export.
return options.exported
? `${prefix}, export render in progress. Results available at:`
: `${prefix}, live preview updated. Edit and export at:`
}
type OrgPlan = 'free' | 'starter' | 'business'
type UploadJobResult = {
@@ -3274,7 +3300,8 @@ async function runTriggeredRecord(
configPath,
playwrightFailure,
verbose,
requestedVideoNames
requestedVideoNames,
'none'
)
} finally {
if (previousPreviewOnly === undefined) {
@@ -3341,7 +3368,8 @@ async function runPreviewRecordPass(
configPath,
playwrightFailure,
verbose,
requestedVideoNames
requestedVideoNames,
'none'
)
} finally {
if (previousPreviewOnly === undefined) {
@@ -3358,6 +3386,439 @@ async function runPreviewRecordPass(
}
}
const EXPORT_POLL_INTERVAL_MS = 5000
const EXPORT_POLL_MAX_ATTEMPTS = 360 // 30 minutes at 5s
type ExportCommandOptions = {
configPath: string | undefined
verbose: boolean
languages: string | undefined
grep: string | undefined
outputDir: string
force: boolean
}
/**
* `screenci export`: produce finished videos and download them.
*
* Records only stale videos (sources changed since the last upload) with the
* export flag set, dispatches backend renders for fresh ones without a local
* re-record, polls until every requested render is terminal, and downloads
* the outputs into the output directory. The CI one-shot: exit code 0 only
* when every requested video finished and downloaded.
*/
async function runExportCommand(options: ExportCommandOptions): Promise<void> {
const resolvedConfigPath = resolveScreenCIConfigPathOrExit(options.configPath)
await loadEnvFileFromConfigSource(resolvedConfigPath, false)
const screenciConfig =
await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath)
const screenciDir = resolve(dirname(resolvedConfigPath), '.screenci')
const apiUrl = getDevBackendUrl()
const appUrl = getDevFrontendUrl()
const grepArgs = options.grep !== undefined ? ['--grep', options.grep] : []
let requestedVideoNames: string[]
try {
requestedVideoNames = await collectRequestedRecordVideoNames(
resolvedConfigPath,
grepArgs,
options.languages
)
} catch (error) {
// Discovery failures (config/test syntax errors) surface with the same
// troubleshooting hints a failed record run gets.
if (!(error instanceof Error)) throw error
throw new RecordFailureHintError(error)
}
const secret = process.env.SCREENCI_SECRET
const recordExportPass = async (
names: readonly string[] | undefined,
grepOverride?: string
): Promise<{
recordId: string | null
projectId: string | null
uploadedVideoNames: string[]
}> => {
const passGrep =
names !== undefined
? names.map((name) => escapeRegExp(name)).join('|')
: grepOverride
const passArgs = passGrep !== undefined ? ['--grep', passGrep] : []
const recordRunLock = await acquireRecordRunLock(
screenciDir,
screenciConfig.projectName
)
// Export runs render; scope the flag to this pass (the env survives the
// Playwright child boundary, so it must be set on the parent process).
const previousExportFlag = process.env['SCREENCI_EXPORT']
process.env['SCREENCI_EXPORT'] = '1'
try {
let playwrightFailure: Error | null = null
if (!isUploadExistingEnabled()) {
try {
await run(
'record',
passArgs,
options.configPath,
options.verbose,
false,
options.languages
)
} catch (error) {
if (!(error instanceof Error)) throw error
if (error.message.startsWith('Playwright exited with code ')) {
playwrightFailure = new RecordFailureHintError(error)
} else {
throw new RecordFailureHintError(error)
}
}
} else {
logger.info(
'UPLOAD_EXISTING set: skipping Playwright recording and re-uploading existing .screenci recordings.'
)
}
const uploaded = await uploadRecordedVideosForConfig(
options.configPath,
playwrightFailure,
options.verbose,
names
)
if (playwrightFailure !== null) {
throw playwrightFailure
}
return uploaded
} finally {
if (previousExportFlag === undefined) {
delete process.env['SCREENCI_EXPORT']
} else {
process.env['SCREENCI_EXPORT'] = previousExportFlag
}
await recordRunLock.release()
}
}
// Anonymous trial: record + render everything requested (the watermark
// path), but downloads need an account, so point at the export page instead.
if (!secret) {
await recordExportPass(
requestedVideoNames.length > 0 ? requestedVideoNames : undefined,
options.grep
)
logger.info(
'Sign up to download finished videos directly from the CLI: ' +
pc.cyan(appUrl)
)
return
}
const fetchInfo = async (recordId?: string): Promise<ExportInfoResponse> => {
const url = new URL(`${apiUrl}/cli/info`)
url.searchParams.set('projectName', screenciConfig.projectName)
if (recordId !== undefined) url.searchParams.set('record', recordId)
const res = await fetch(url.toString(), {
headers: { 'X-ScreenCI-Secret': secret },
})
if (!res.ok) {
const text = await res.text()
throw new Error(
`Failed to fetch render status: ${res.status} ${extractBackendError(text)}${hint401(res.status, secret)}`
)
}
return (await res.json()) as ExportInfoResponse
}
// Discovery found nothing: either the project has no matching videos or the
// discovery pass failed silently. Run the record pass anyway so Playwright
// reports the real problem, and export whatever it uploads.
if (requestedVideoNames.length === 0) {
const uploaded = await recordExportPass(undefined, options.grep)
if (
uploaded.recordId === null ||
uploaded.uploadedVideoNames.length === 0
) {
logger.error(
options.grep !== undefined
? `No videos match "${options.grep}".`
: 'No videos found. Declare one with video(...) in your recordings.'
)
process.exitCode = 1
return
}
await pollAndDownloadExports({
targets: [
{
recordId: uploaded.recordId,
videoNames: uploaded.uploadedVideoNames,
},
],
languagesCsv: options.languages,
outputDir: options.outputDir,
fetchInfo,
secret,
})
return
}
// Partition: fresh videos render server-side from their last upload; stale
// ones (or ones the server does not know) re-record first.
const kept = await readKeptRecordingsByVideoName(screenciDir)
const lastUpload = await readLastUpload(screenciDir)
const partition = await partitionExportVideos({
requestedNames: requestedVideoNames,
keptByVideoName: kept,
uploadedVideos: lastUpload.videos,
hashSource: hashSourceFile,
force: options.force,
})
// A "fresh" video the server has never seen cannot render remotely; demote
// it to the stale set so it records and uploads like any new video.
let fresh = partition.fresh
const stale = [...partition.stale]
let videoIdByName = new Map<string, string>()
if (fresh.length > 0) {
const info = await fetchInfo()
videoIdByName = new Map(
Object.entries(info.videos).map(([name, video]) => [name, video.videoId])
)
const unknown = fresh.filter((name) => !videoIdByName.has(name))
if (unknown.length > 0) {
stale.push(...unknown)
fresh = fresh.filter((name) => videoIdByName.has(name))
}
}
const targets: ExportPollTarget[] = []
if (stale.length > 0) {
logger.info(
`Recording ${stale.length} changed video${stale.length === 1 ? '' : 's'}: ${stale.join(', ')}`
)
const uploaded = await recordExportPass(stale)
if (uploaded.recordId === null) {
logger.error('Recording upload failed; nothing to export.')
process.exit(1)
}
targets.push({ recordId: uploaded.recordId, videoNames: stale })
}
if (fresh.length > 0) {
logger.info(
`Rendering ${fresh.length} up-to-date video${fresh.length === 1 ? '' : 's'} without re-recording: ${fresh.join(', ')}`
)
const requestedLanguages = options.languages
?.split(',')
.map((language) => language.trim())
.filter((language) => language.length > 0)
for (const name of fresh) {
const videoId = videoIdByName.get(name)
if (videoId === undefined) continue
const res = await fetch(`${apiUrl}/cli/render`, {
method: 'POST',
headers: {
'X-ScreenCI-Secret': secret,
'Content-Type': 'application/json',
},
body: JSON.stringify({
videoId,
...(requestedLanguages !== undefined && requestedLanguages.length > 0
? { languages: requestedLanguages }
: {}),
}),
})
if (!res.ok) {
const text = await res.text()
logger.error(
`Failed to start the render for "${name}": ${res.status} ${extractBackendError(text)}${hint401(res.status, secret)}`
)
process.exit(1)
}
const { recordId } = (await res.json()) as { recordId: string }
targets.push({ recordId, videoNames: [name] })
}
}
await pollAndDownloadExports({
targets,
languagesCsv: options.languages,
outputDir: options.outputDir,
fetchInfo,
secret,
})
}
/** Shared export tail: wait for the renders, download, summarize, set exit code. */
async function pollAndDownloadExports(params: {
targets: readonly ExportPollTarget[]
languagesCsv: string | undefined
outputDir: string
fetchInfo: (recordId: string) => Promise<ExportInfoResponse>
secret: string
}): Promise<void> {
const requestedLanguages = params.languagesCsv
?.split(',')
.map((language) => language.trim())
.filter((language) => language.length > 0)
logger.info('Waiting for renders to finish...')
const results = await pollExportRenders({
targets: params.targets,
...(requestedLanguages !== undefined && requestedLanguages.length > 0
? { languages: requestedLanguages }
: {}),
intervalMs: EXPORT_POLL_INTERVAL_MS,
maxAttempts: EXPORT_POLL_MAX_ATTEMPTS,
deps: {
fetchInfo: params.fetchInfo,
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
log: (message) => logger.info(message),
},
})
const outDir = resolve(process.cwd(), params.outputDir)
const downloads = await downloadExportOutputs({
results,
outDir,
deps: {
fetchFn: (url) =>
fetch(url, { headers: { 'X-ScreenCI-Secret': params.secret } }),
mkdir: async (dir) => {
mkdirSync(dir, { recursive: true })
},
writeFile: async (path, data) => {
await writeFile(path, data)
},
},
})
printExportSummary(results, downloads, outDir)
process.exitCode = exportExitCode(results, downloads)
}
function printExportSummary(
results: readonly ExportRenderResult[],
downloads: readonly {
videoName: string
language: string
filePath: string | null
error?: string
}[],
outDir: string
): void {
logger.info('')
for (const result of results) {
const download = downloads.find(
(d) => d.videoName === result.videoName && d.language === result.language
)
const label = `${result.videoName} (${result.language})`
if (result.status === 'finished' && download?.filePath != null) {
logger.info(` ${pc.green('✓')} ${label} -> ${download.filePath}`)
} else if (result.status === 'finished') {
logger.warn(
` ${pc.red('✗')} ${label}: download failed${download?.error ? ` (${download.error})` : ''}`
)
} else if (result.status === 'failed') {
logger.warn(
` ${pc.red('✗')} ${label}: render failed${result.failureMessage ? ` (${result.failureMessage})` : ''}`
)
} else {
logger.warn(` ${pc.red('✗')} ${label}: timed out waiting for the render`)
}
}
const finishedCount = downloads.filter((d) => d.filePath !== null).length
if (finishedCount > 0) {
logger.info('')
logger.info(
`Exported ${finishedCount} file${finishedCount === 1 ? '' : 's'} to ${outDir}`
)
}
}
/**
* Resolves the single video `screenci edit` manages. Edit deep-links the web
* editor for one video, so the pattern must match exactly one; zero or many
* matches exit with the matching/available titles listed.
*/
export function resolveSingleEditVideo(
allVideoNames: readonly string[],
grep: string | undefined,
suggestCommand: (name: string) => string
): { ok: true; videoName: string } | { ok: false; message: string } {
const formatList = (names: readonly string[]): string =>
names.map((name) => ` - ${name}`).join('\n')
if (allVideoNames.length === 0) {
return {
ok: false,
message:
'No videos found. Declare one with video(...) in your recordings.',
}
}
const matches =
grep === undefined
? [...allVideoNames]
: allVideoNames.filter(grepMatcher(grep))
if (matches.length === 1) {
return { ok: true, videoName: matches[0]! }
}
if (matches.length === 0) {
return {
ok: false,
message:
`No video matches "${grep}". Available videos:\n` +
formatList(allVideoNames),
}
}
const intro =
grep === undefined
? `screenci edit opens the editor for one video at a time. This project has ${matches.length} videos:`
: `screenci edit opens the editor for one video at a time. "${grep}" matches ${matches.length} videos:`
return {
ok: false,
message:
`${intro}\n` +
formatList(matches) +
`\nPick one, e.g. ${suggestCommand(matches[0]!)}`,
}
}
/**
* Best-effort editor deep link for the edited video: resolves projectId and
* videoId from `/cli/info` and prints the studio URL. A video the server does
* not know yet (first upload still pending or failed) prints nothing.
*/
async function printEditorLink(params: {
apiUrl: string
appUrl: string
secret: string
projectName: string
videoName: string
}): Promise<void> {
try {
const url = new URL(`${params.apiUrl}/cli/info`)
url.searchParams.set('projectName', params.projectName)
const res = await fetch(url.toString(), {
headers: { 'X-ScreenCI-Secret': params.secret },
})
if (!res.ok) return
const info = (await res.json()) as {
projectId?: string
videos?: Record<string, { videoId?: string }>
}
const videoId = info.videos?.[params.videoName]?.videoId
if (typeof info.projectId !== 'string' || typeof videoId !== 'string') {
return
}
logger.info('')
logger.info(`Edit "${params.videoName}" at:`)
logger.info(
pc.cyan(formatStudioUrl(params.appUrl, info.projectId, videoId))
)
} catch {
// Best-effort only: the editor link is a convenience, never a failure.
}
}
export async function runDevCommand(
options: {
config?: string
@@ -3365,6 +3826,8 @@ export async function runDevCommand(
token?: string
recordKillWindow?: string
grep?: string
/** The single video this edit session manages (editor deep link). */
videoName?: string
forceRecord?: boolean
/** False disables the source-file watcher (--no-watch). */
watch?: boolean
@@ -3387,6 +3850,29 @@ export async function runDevCommand(
process.exit(1)
}
// Edit deep-links the web editor for a single video, so the pattern must
// resolve to exactly one; anything else lists the candidates and exits.
// Skipped when the caller (tests) pre-resolved the video name.
if (options.videoName === undefined) {
const editConfigPath = resolveScreenCIConfigPathOrExit(options.config)
const allVideoNames = await collectRequestedRecordVideoNames(
editConfigPath,
[],
undefined
)
const resolution = resolveSingleEditVideo(
allVideoNames,
options.grep,
(name) => pc.cyan(`${getSuggestedScreenciCommand('edit')} "${name}"`)
)
if (!resolution.ok) {
logger.error(resolution.message)
process.exit(1)
}
options.videoName = resolution.videoName
options.grep = escapeRegExp(resolution.videoName)
}
const killWindowSeconds = Number(options.recordKillWindow)
const config: DevListenConfig = {
apiUrl,
@@ -3642,6 +4128,18 @@ export async function runDevCommand(
)
}
// With the managed videos up to date, point at the editor for the video
// this session manages instead of any run page.
if (options.videoName !== undefined) {
await printEditorLink({
apiUrl,
appUrl: getDevFrontendUrl(),
secret,
projectName: screenciConfig.projectName,
videoName: options.videoName,
})
}
// Watch the managed videos' source files (and the config) so saving a test
// source re-records its previews without a manual trigger.
if (options.watch !== false) {
@@ -3888,19 +4386,32 @@ function getLastRecordFilePath(screenciDir: string): string {
}
/**
* Persists the recordId of the just-completed `screenci record` upload so a
* later `screenci info` can report exactly that run. Best-effort: a
* failure to write must not fail the record command.
* Persists the recordId of the just-completed upload so a later `screenci
* info` can report exactly that run, plus a per-video map of the uploaded
* source hashes so `screenci export` can skip re-recording videos whose
* sources have not changed since the upload. Existing entries for videos not
* in this upload are kept. Best-effort: a failure to write must not fail the
* command.
*/
async function saveLastRecordId(
screenciDir: string,
recordId: string
recordId: string,
uploadedVideos: Record<string, UploadedVideoState> = {}
): Promise<void> {
try {
const previous = await readLastUpload(screenciDir)
mkdirSync(screenciDir, { recursive: true })
await writeFile(
getLastRecordFilePath(screenciDir),
`${JSON.stringify({ recordId, savedAt: new Date().toISOString() }, null, 2)}\n`
`${JSON.stringify(
{
recordId,
savedAt: new Date().toISOString(),
videos: { ...previous.videos, ...uploadedVideos },
},
null,
2
)}\n`
)
} catch (err) {
logger.warn(
@@ -3909,21 +4420,90 @@ async function saveLastRecordId(
}
}
async function readLastRecordId(screenciDir: string): Promise<string | null> {
async function readLastUpload(screenciDir: string): Promise<{
recordId: string | null
videos: Record<string, UploadedVideoState>
}> {
try {
const raw = await readFile(getLastRecordFilePath(screenciDir), 'utf-8')
const parsed = JSON.parse(raw) as { recordId?: unknown }
return typeof parsed.recordId === 'string' ? parsed.recordId : null
const parsed = JSON.parse(raw) as { recordId?: unknown; videos?: unknown }
const videos: Record<string, UploadedVideoState> = {}
if (typeof parsed.videos === 'object' && parsed.videos !== null) {
for (const [name, state] of Object.entries(
parsed.videos as Record<string, unknown>
)) {
if (
typeof state === 'object' &&
state !== null &&
typeof (state as { sourceHash?: unknown }).sourceHash === 'string'
) {
videos[name] = {
sourceHash: (state as { sourceHash: string }).sourceHash,
}
}
}
}
return {
recordId: typeof parsed.recordId === 'string' ? parsed.recordId : null,
videos,
}
} catch (err) {
if (!isMissingFileError(err)) {
logger.warn(
`Ignoring invalid stored record at ${getLastRecordFilePath(screenciDir)}.`
)
}
return null
return { recordId: null, videos: {} }
}
}
async function readLastRecordId(screenciDir: string): Promise<string | null> {
return (await readLastUpload(screenciDir)).recordId
}
/**
* Reads every kept recording under `.screenci` keyed by video name (the same
* kept data the edit handshake uses for freshness).
*/
async function readKeptRecordingsByVideoName(
screenciDir: string
): Promise<Map<string, KeptRecordingData>> {
const byName = new Map<string, KeptRecordingData>()
if (!existsSync(screenciDir)) return byName
for (const entry of readdirSync(screenciDir)) {
const dir = resolve(screenciDir, entry)
if (statSync(dir, { throwIfNoEntry: false })?.isDirectory() !== true) {
continue
}
const data = await readKeptRecordingData(dir)
const videoName = data?.metadata?.videoName
// Per-language recordings share a videoName; one language's data suffices.
if (data !== null && videoName !== undefined && !byName.has(videoName)) {
byName.set(videoName, data)
}
}
return byName
}
/**
* Source hashes of the just-uploaded videos, read from the kept recording
* data, for the last-upload map that drives export's fresh/stale partition.
*/
async function collectUploadedSourceHashes(
screenciDir: string,
uploadedVideoNames: readonly string[]
): Promise<Record<string, UploadedVideoState>> {
const kept = await readKeptRecordingsByVideoName(screenciDir)
const result: Record<string, UploadedVideoState> = {}
for (const name of uploadedVideoNames) {
const sourceHash = kept.get(name)?.metadata?.sourceHash
if (typeof sourceHash === 'string') {
result[name] = { sourceHash }
}
}
return result
}
// `screenci info` prints every project video and its public URLs as JSON. When
// this machine has recorded a run (a recordId is stored in
// .screenci/last-record.json), the backend also attaches, to the videos from
@@ -4053,14 +4633,26 @@ async function uploadRecordedVideosForConfig(
configPath: string | undefined,
playwrightFailure: Error | null,
verbose: boolean,
requestedVideoNames?: readonly string[]
): Promise<void> {
requestedVideoNames?: readonly string[],
// 'export' prints the export run page URL after a successful upload;
// 'none' stays quiet (edit preview uploads print the editor URL instead).
resultLink: 'export' | 'none' = 'export'
): Promise<{
recordId: string | null
projectId: string | null
uploadedVideoNames: string[]
}> {
// After recording, upload results to API if configured. `run` already
// resolved the config (or exited), so this best-effort lookup only acts
// when a flat config is present in/under the current directory.
const resolution = findScreenCIConfig(configPath)
if (resolution.kind !== 'found') return
if (resolution.kind !== 'found') {
return { recordId: null, projectId: null, uploadedVideoNames: [] }
}
let uploadedRecordId: string | null = null
let uploadedProjectId: string | null = null
let uploadedNames: string[] = []
const resolvedConfigPath = resolution.path
try {
const screenciConfig =
@@ -4158,9 +4750,17 @@ async function uploadRecordedVideosForConfig(
requestedVideoNames.every((videoName) =>
uploadedVideoNames.includes(videoName)
))
// Remember this run so `screenci info` can report exactly it.
// Remember this run so `screenci info` can report exactly it, plus the
// uploaded source hashes so `screenci export` can skip fresh videos.
if (recordId !== null && requestedUploadSucceeded) {
await saveLastRecordId(screenciDir, recordId)
uploadedRecordId = recordId
uploadedProjectId = projectId
uploadedNames = uploadedVideoNames
await saveLastRecordId(
screenciDir,
recordId,
await collectUploadedSourceHashes(screenciDir, uploadedVideoNames)
)
}
// Emit upload-failure warnings (stderr) before the results block.
// logger.info writes to stdout, logger.warn to stderr; in non-TTY CI
@@ -4179,26 +4779,37 @@ async function uploadRecordedVideosForConfig(
)
}
let resultUrl: string | null = null
if (requestedUploadSucceeded && recordId !== null && projectId !== null) {
const recordUrl = `${appUrl}/record/${recordId}`
resultUrl = recordUrl
await writeGitHubProjectOutput(recordUrl)
if (
resultLink === 'export' &&
requestedUploadSucceeded &&
recordId !== null &&
projectId !== null
) {
const exportUrl = `${appUrl}/export/${recordId}`
resultUrl = exportUrl
await writeGitHubProjectOutput(exportUrl)
logger.info('')
logger.info(
playwrightFailure !== null
? 'Recording partially succeeded, rendering in progress. Results available at:'
: 'Recording finished, rendering in progress. Results available at:'
formatRecordResultMessage({
exported: process.env['SCREENCI_EXPORT'] === '1',
partial: playwrightFailure !== null,
})
)
logger.info(pc.cyan(recordUrl))
} else if (requestedUploadSucceeded && projectId !== null) {
logger.info(pc.cyan(exportUrl))
} else if (
resultLink === 'export' &&
requestedUploadSucceeded &&
projectId !== null
) {
const projectUrl = `${appUrl}/project/${projectId}`
resultUrl = projectUrl
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:'
formatRecordResultMessage({
exported: process.env['SCREENCI_EXPORT'] === '1',
partial: playwrightFailure !== null,
})
)
logger.info(pc.cyan(projectUrl))
}
@@ -4275,13 +4886,18 @@ async function uploadRecordedVideosForConfig(
}
logger.warn('Failed to load config for upload:', err)
}
return {
recordId: uploadedRecordId,
projectId: uploadedProjectId,
uploadedVideoNames: uploadedNames,
}
}
export async function main() {
if (process.argv.length <= 2) {
logger.error('Error: No command provided')
logger.error(
'Available commands: record, dev, test, info, make-public, make-private, delete, init'
'Available commands: edit, export, test, info, make-public, make-private, delete, init'
)
process.exit(1)
}
@@ -4291,127 +4907,70 @@ export async function main() {
program.name('screenci')
program.exitOverride()
// record command — playwright args pass through as-is
// export command: record what changed, render, wait, and download mp4s
program
.command('record [playwrightArgs...]')
.description('Record videos using Playwright')
.command('export [patterns...]')
.description(
'Export finished videos: re-record changed videos, render, wait, and ' +
'download the outputs. Positional patterns filter videos by title; ' +
'no patterns exports every video.'
)
.option('-c, --config <path>', 'path to config file')
.option('-v, --verbose', 'verbose output')
.option(
'--remote',
'trigger the GitHub Actions recording workflow for this project remotely instead of recording locally'
'trigger the GitHub Actions recording workflow for this project remotely instead of exporting locally'
)
.option(
'--languages <langs>',
'record/render only these languages (comma-separated, e.g. fi,en)'
'export only these languages (comma-separated, e.g. fi,en)'
)
.option(
'--no-render',
'upload the recording and its editable data without dispatching a ' +
'render (fast editor sync; render later with a normal record)'
'-g, --grep <pattern>',
'only export videos whose title matches this pattern (same filter as playwright --grep)'
)
.option(
'--export',
'export a finished video from this recording. Without it the upload ' +
'refreshes the live preview only; export minutes are spent on ' +
'export. (deprecated aliases: --publish, --render)'
'-o, --output <dir>',
'directory for the downloaded files (default: exports)'
)
.allowUnknownOption(true)
.action(async () => {
const parsed = parseRecordCliArgs(getSubcommandArgv('record'))
if (parsed.noRender) {
// Read where the upload start request is built; env so the flag
// survives the Playwright child process boundary.
process.env['SCREENCI_SKIP_RENDER'] = '1'
}
if (parsed.exportVideo) {
// Preview-first: uploads only render when explicitly exported.
process.env['SCREENCI_EXPORT'] = '1'
}
.option(
'--force',
're-record every video even when its recording is up to date'
)
.action(
async (
patterns: string[],
options: {
config?: string
verbose?: boolean
remote?: boolean
languages?: string
grep?: string
output?: string
force?: boolean
}
) => {
const positionalGrep =
patterns.length > 0 ? patterns.map(escapeRegExp).join('|') : undefined
const grep = options.grep ?? positionalGrep
// `--remote` is a pure dispatch: it fires the project's GitHub Actions
// recording workflow and exits, so there is no local Playwright run. A
// pass-through `--grep` becomes the remote recording filter, and
// `--languages` limits which language versions are recorded.
if (parsed.remote) {
await triggerRemoteRun(
parsed.configPath,
extractGrep(parsed.otherArgs),
parsed.languages
)
return
}
validateArgs(parsed.otherArgs)
const resolvedConfigPath = resolveScreenCIConfigPathOrExit(
parsed.configPath
)
await loadEnvFileFromConfigSource(resolvedConfigPath, false)
const screenciConfig =
await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath)
const screenciDir = resolve(dirname(resolvedConfigPath), '.screenci')
const requestedVideoNames =
parsed.otherArgs.length > 0 || parsed.languages !== undefined
? await collectRequestedRecordVideoNames(
resolvedConfigPath,
parsed.otherArgs,
parsed.languages
)
: undefined
const recordRunLock = await acquireRecordRunLock(
screenciDir,
screenciConfig.projectName
)
try {
let playwrightFailure: Error | null = null
// UPLOAD_EXISTING re-sends the recordings already on disk under `.screenci`
// without re-running Playwright (resend the last local run when only the
// upload failed). We skip the recording run and fall straight through to
// the upload below, treating the on-disk recordings as the complete set.
const uploadExisting = isUploadExistingEnabled()
if (!uploadExisting) {
try {
await run(
'record',
parsed.otherArgs,
parsed.configPath,
parsed.verbose,
false,
parsed.languages
)
} catch (error) {
if (!(error instanceof Error)) throw error
if (error.message.startsWith('Playwright exited with code ')) {
playwrightFailure = new RecordFailureHintError(error)
} else {
throw new RecordFailureHintError(error)
}
}
} else {
logger.info(
'UPLOAD_EXISTING set: skipping Playwright recording and re-uploading existing .screenci recordings.'
)
// `--remote` is a pure dispatch: it fires the project's GitHub Actions
// recording workflow and exits; there is no local run or download.
if (options.remote === true) {
await triggerRemoteRun(options.config, grep, options.languages)
return
}
if (process.env.SCREENCI_RECORDING === 'true') return
await uploadRecordedVideosForConfig(
parsed.configPath,
playwrightFailure,
parsed.verbose,
requestedVideoNames
)
if (playwrightFailure !== null) {
throw playwrightFailure
}
} finally {
await recordRunLock.release()
await runExportCommand({
configPath: options.config,
verbose: options.verbose ?? false,
languages: options.languages,
grep,
outputDir: options.output ?? 'exports',
force: options.force ?? false,
})
}
})
)
// dev command: connect this machine to the web editor and record on demand
program
@@ -4520,9 +5079,11 @@ export async function main() {
if (process.env.SCREENCI_RECORDING === 'true') return
const recordCommand = getSuggestedScreenciCommand('record')
const editCommand = getSuggestedScreenciCommand('edit')
logger.info(
`Tests passed. Run ${pc.cyan(recordCommand)} to render the videos.`
`Tests passed. Run ${pc.cyan(editCommand)} to record and edit a video, or ${pc.cyan(
getSuggestedScreenciCommand('export')
)} to export finished videos.`
)
})
+12 -13
View File
@@ -39,21 +39,20 @@ The brief walks the agent through these steps:
2. **Author a video** for the flow you described, including narration via
`video.narration({...})`, with setup wrapped in `hide()` and the demo
driven with visible clicks.
3. **Verify, then record.** It runs `npx screenci test` until the script is
green, then `npx screenci record` to capture the flow. `record` uploads
immediately either way: without a `SCREENCI_SECRET`, under a local,
3. **Verify, then export.** It runs `npx screenci test` until the script is
green, then `npx screenci export` to capture and render the flow. `export`
uploads immediately either way: without a `SCREENCI_SECRET`, under a local,
anonymous trial session (it prints a link to view the result and sign up to
keep it); with one, straight to your organization. By default the upload
refreshes the video's live preview in the web editor; exporting the
finished video (`npx screenci record --export`, or the Export button in the
editor) is a separate step that spends export minutes. Exports without an
account, and exports on the free tier, include a ScreenCI watermark;
signing up (or upgrading) removes it.
keep it; downloads require an account); with one, straight to your
organization, waiting for the renders and downloading the finished files.
Exports without an account, and exports on the free tier, include a
ScreenCI watermark; signing up (or upgrading) removes it.
Once the video exists, refinement moves to the browser: open it in the web
app and run `screenci edit` in the project, and every edit you make in the
[Editor](/docs/guides/editor) (narration, overlays, cuts, timings) is written
back into the script, ready for the agent or you to build on.
Once the video exists, refinement moves to the browser: run
`screenci edit "<video title>"` in the project. It records the live preview if
needed, prints the web editor link, and stays connected, so every edit you
make in the [Editor](/docs/editor) (narration, overlays, cuts, timings) is
written back into the script, ready for the agent or you to build on.
## How the agent gathers context
+13 -10
View File
@@ -1,10 +1,13 @@
# Anonymous Trial
`screenci record` needs no account or `SCREENCI_SECRET` to try. Without one, a
recording uploads under a local, anonymous trial session: the CLI prints a link
to view the result, and you sign up afterward to keep it. Recording an
anonymous trial agrees to the [Terms](https://screenci.com/legal/tos), which
`record` prints before it starts.
`screenci export` needs no account or `SCREENCI_SECRET` to try. Without one, a
recording uploads under a local, anonymous trial session: it records and
renders with the trial watermark, and the CLI prints a link to view the result
(downloads require an account, so the trial prints the export page URL and a
sign-up hint instead of writing files). You sign up afterward to keep it.
Recording an anonymous trial agrees to the
[Terms](https://screenci.com/legal/tos), which `export` prints before it
starts.
## What the trial includes
@@ -24,15 +27,15 @@ account without an active paid plan.
## Trial limits
An anonymous trial gives up to three `screenci record` calls, each capped
An anonymous trial gives up to three `screenci export` calls, each capped
further within that call:
- **Three recording calls.** You can run `screenci record` up to three times on
- **Three recording calls.** You can run `screenci export` up to three times on
the trial. After each successful call the CLI prints how many recordings are
left. Once all three are used (or the trial session expires), a further
`screenci record` stops before it starts and prints a sign-up link instead of
`screenci export` stops before it starts and prints a sign-up link instead of
recording again.
- **Up to 3 videos** in each call.
- **Up to 3 videos and screenshots** in each call.
- **Up to 3 narration languages at once**, combined across everything in a
call.
- **1080p maximum resolution.** 4K output requires signing up for the
@@ -58,4 +61,4 @@ on your paid plan whenever you want a watermark-free version of it.
works once you are signed up.
- [Narration](/docs/guides/narration) for expressive voices, style prompts,
and tone control.
- [CLI](/docs/reference/cli) for the full `record` command reference.
- [CLI](/docs/reference/cli) for the full `export` command reference.
+17 -16
View File
@@ -22,14 +22,14 @@ left untouched on re-run.
The workflow runs on pushes to `main` and on
[`workflow_dispatch`](https://docs.github.com/en/actions/using-workflows/manually-running-a-workflow),
installs Node.js 24 with dependency caching, installs the Playwright Chromium
Headless Shell, and runs `screenci record`. It mirrors
Headless Shell, and runs `screenci export`. It mirrors
[Playwright CI](https://playwright.dev/docs/ci). Use `push` to keep videos current
automatically, or `workflow_dispatch` for a manual approval step before recording.
automatically, or `workflow_dispatch` for a manual approval step before exporting.
By default the workflow's `record` refreshes each video's live preview in the
editor without spending export minutes; no finished video is rendered. To make
CI export finished videos, change the record step to
`screenci record --export` (this spends export minutes on every run).
`export` re-records only the videos whose sources changed since the last
upload, dispatches renders for the rest without re-recording them, waits for
the renders to finish, and downloads the outputs. Export minutes are spent on
every video that renders in the run.
## Required secret
@@ -42,7 +42,7 @@ early if it is missing.
If your videos navigate to a locally-running app via `webServer` in
`screenci.config.ts`, the generated workflow needs two extra steps so the app
is built and reachable when `screenci record` runs.
is built and reachable when `screenci export` runs.
### Update `screenci.config.ts`
@@ -184,22 +184,22 @@ a short-lived token scoped to only `Actions: write` on the repositories you pick
Once connected, you can dispatch the recording workflow two ways:
- **From the app:** click **Record all** on the project page. To record a single
video, use its **Record** button (on the project page or its
video or screenshot, use its **Record** button (on the project page or its
detail page).
- **From the CLI:** run [`screenci record --remote`](/docs/reference/cli#-remote).
- **From the CLI:** run [`screenci export --remote`](/docs/reference/cli#-remote).
It resolves the project from `SCREENCI_SECRET` and triggers the workflow without
recording locally.
### Targeted recordings
You can record only some videos instead of all of them, from
You can record only some videos or screenshots instead of all of them, from
either surface:
- **From the app:** the per-item **Record** buttons.
- **From the CLI:** pass `--grep` to filter by title, just like local recording:
```bash
screenci record --remote --grep "Onboarding"
screenci export --remote --grep "Onboarding"
```
Both forward the filter to the workflow's optional `grep` input, which
@@ -211,11 +211,12 @@ from your GitHub settings.
## Reading back render status
Rendering happens after `record --export` uploads (a plain `record` refreshes
the preview and dispatches no render), so a green record step does not mean
videos are rendered. Run [`screenci info`](/docs/reference/cli#screenci-info) to
read each language's render status (`finished`, `rendering`, or `failed`) and
public URLs as JSON. A CI job can poll until `finished` or gate on `failed`.
`screenci export` already waits for renders and exits `0` only when every
requested video rendered and downloaded, so a green export step means the
videos are done. To read the results back later (or from another job), run
[`screenci info`](/docs/reference/cli#screenci-info): it reports each
language's render status (`finished`, `rendering`, or `failed`) and public
URLs as JSON.
## What's next
+99 -86
View File
@@ -3,23 +3,25 @@
import { Tabs, TabItem } from '@astrojs/starlight/components'
The `screenci` CLI keeps the workflow small: initialize a project, iterate
locally, record final output, and manage public delivery when needed. Run
locally with `screenci test`, refine one video with `screenci edit` and the web
editor, export the finished videos with `screenci export`, and manage public
delivery when needed. Run
commands from inside your `screenci/` project directory. Most commands resolve
`screenci.config.ts` from the current directory unless you pass
`--config <path>`.
## Command overview
| Command | Purpose |
| ---------------------------------- | ----------------------------------------------------------------------------- |
| `screenci init [name]` | Scaffold a ScreenCI project |
| `screenci test [playwrightArgs]` | Run `.screenci.ts` files locally without final recording |
| `screenci record [playwrightArgs]` | Record videos and upload results when configured |
| `screenci edit` | Connect this machine to the editor: unlocks web editing and on-demand records |
| `screenci info` | Print the last record run's URLs and render status |
| `screenci make-public <videoId>` | Enable public delivery for a video |
| `screenci make-private <videoId>` | Disable public delivery for a video |
| `screenci delete <videoId>` | Permanently delete a video and its renders |
| Command | Purpose |
| --------------------------------- | ---------------------------------------------------------------------------------- |
| `screenci init [name]` | Scaffold a ScreenCI project |
| `screenci test [playwrightArgs]` | Run `.screenci.ts` files locally without final recording |
| `screenci edit [pattern]` | Open the web editor for one video and connect this machine as its code-sync bridge |
| `screenci export [patterns...]` | Produce finished videos: re-record what changed, render, and download |
| `screenci info` | Print the last record run's URLs and render status |
| `screenci make-public <videoId>` | Enable public delivery for a video |
| `screenci make-private <videoId>` | Disable public delivery for a video |
| `screenci delete <videoId>` | Permanently delete a video and its renders |
## `screenci init`
@@ -53,10 +55,10 @@ The project name defaults to the repository root directory name.
### Connecting to an existing account
No account or secret is needed to scaffold or record: without one, `record`
No account or secret is needed to scaffold or record: without one, `export`
uploads under a local, anonymous trial session and prints a link to view the
result, then you sign up to keep it. Recording an anonymous trial agrees to the
[Terms](https://screenci.com/legal/tos), which `record` prints before it starts.
[Terms](https://screenci.com/legal/tos), which `export` prints before it starts.
To connect the project to an account you already have, pass your
`SCREENCI_SECRET` (from your secrets page) as `init`'s positional argument, and
@@ -154,7 +156,7 @@ Plain `screenci test` disables recording-style waits so authoring runs stay
fast. That includes ScreenCI cursor/camera pauses and
`page.waitForTimeout(...)`, which is collapsed to `0ms`. Use Playwright locator
or load-state waits for real application readiness. Use `--mock-record` when you
need to preview the same pacing that `screenci record` will capture.
need to preview the same pacing that a real recording will capture.
Common Playwright examples that also work here:
@@ -215,101 +217,104 @@ yarn screenci test --mock-record
</Tabs>
This keeps recording-like pacing enabled without starting the real recording
capture path. Use it when `test` passes but `record` exposes timing
capture path. Use it when `test` passes but a real recording exposes timing
differences.
If you want that behavior by default for a project, set
`test.mockRecord: true` in `screenci.config.ts`.
## `screenci record [playwrightArgs...]`
## `screenci export [patterns...]`
Record final output:
Produce finished videos and download them. `export` is the one-shot command
(also the one CI runs): it re-records only the videos whose sources changed
since the last upload, dispatches renders for videos that are already up to
date without re-recording them, waits for the renders to finish, and downloads
the outputs into `./exports/`:
<Tabs syncKey="package-manager">
<TabItem label="npm">
```bash
npx screenci record
npx screenci record recordings/onboarding.screenci.ts
npx screenci record --grep "billing"
npx screenci record --project=chromium
npx screenci export
npx screenci export "Onboarding"
npx screenci export --grep "billing"
npx screenci export --languages fi,en
```
</TabItem>
<TabItem label="pnpm">
```bash
pnpm exec screenci record
pnpm exec screenci record recordings/onboarding.screenci.ts
pnpm exec screenci record --grep "billing"
pnpm exec screenci record --project=chromium
pnpm exec screenci export
pnpm exec screenci export "Onboarding"
pnpm exec screenci export --grep "billing"
pnpm exec screenci export --languages fi,en
```
</TabItem>
<TabItem label="yarn">
```bash
yarn screenci record
yarn screenci record recordings/onboarding.screenci.ts
yarn screenci record --grep "billing"
yarn screenci record --project=chromium
yarn screenci export
yarn screenci export "Onboarding"
yarn screenci export --grep "billing"
yarn screenci export --languages fi,en
```
</TabItem>
</Tabs>
`record` forwards normal Playwright file filters and `--grep`, so you can limit
recording to only some videos just like with `screenci test`.
Positional arguments are title patterns (multiple are OR-combined, like
`playwright test <pattern>`); with no patterns, every video is exported in
every language. Downloads are named `<title>.<lang>.mp4` (screenshots use
`.png`). The command polls render status every 5 seconds for up to 30 minutes
and exits `0` only when every requested video rendered and downloaded. After a
run it prints the run's page in the app (`<app>/export/<recordId>`), which the
project page lists under **Exports**.
### `--languages <langs>`
Record (and render) only specific language versions of videos declared with
Export only specific language versions of videos declared with
`video.languages([...])`. Pass a comma-separated list:
```bash
npx screenci record --languages fi
npx screenci record --languages fi,en
npx screenci export --languages fi
npx screenci export --languages fi,en
```
Per-language videos record only the requested languages, so a run never produces
more than you asked for. Videos that do not declare those languages are skipped.
A shared-mode recording (`{ mode: 'shared' }`) is a single capture and is not
split by this filter. See
Per-language videos record and render only the requested languages, so a run
never produces more than you asked for. Videos that do not declare those
languages are skipped. A shared-mode recording (`{ mode: 'shared' }`) is a
single capture and is not split by this filter. See
[Languages](/docs/guides/languages) for the recording API.
### `--export`
### `-o, --output <dir>`
Export a finished video from this recording. Without `--export`, `record`
refreshes the video's live preview in the Editor only, so you can iterate for
free and export when you are happy. Export minutes are spent only on export:
Directory for the downloaded files. Defaults to `exports`:
```bash
npx screenci record --export
npx screenci export -o dist/videos
```
`--publish` and `--render` are accepted as deprecated aliases.
### `--force`
### `--no-render`
Upload the recording and its editable timeline data without dispatching a
render. Useful for a fast first sync with the web editor: the timeline, the
action identities (`editId` slugs), and the editable parameters land in the
Editor in seconds, without waiting for (or spending) a render. Export later
with `screenci record --export`:
Re-record every requested video, even the ones whose recordings are up to
date. Without it, `export` skips re-recording fresh videos and only dispatches
their renders:
```bash
npx screenci record --no-render
npx screenci export --force
```
Behavior:
- enables recording timing
- enables recording timing for any video that needs re-recording
- writes local output into `.screenci/`
- holds a per-project run lock at `.screenci/.record.lock` while recording and uploading. If another `screenci record` is already active, the second run exits non-zero instead of sharing the same `.screenci` output. Stale locks are reclaimed automatically when the pid is gone or the lock is older than the maximum run TTL
- needs no `SCREENCI_SECRET` to run: without one, it uploads under a local, anonymous trial session and prints a link to view the result. Recording an anonymous trial agrees to the [Terms](https://screenci.com/legal/tos), which `record` prints before it starts. To upload straight to your organization instead, set `SCREENCI_SECRET` (from the project `.env`, the environment, or by running `init` with it) before recording
- gives an anonymous trial exactly one recording. Once that first recording is used (or the trial expires), a second `screenci record` stops before it starts and prints a sign-up link instead of recording again, so no time is spent rendering a video that would only be refused. Sign up to keep recording; once you have, re-running `record` in the same folder links your account automatically and uploads normally. The trial previews expressive narration and up to 3 languages at once; see [Anonymous Trial](/docs/guides/anonymous-trial) for its full limits and what happens to that content after signup
- uploads only the recordings requested by this run (for example the videos matched by `--grep` or `--languages`), with or without `SCREENCI_SECRET`
- prints a results URL only when the requested recordings uploaded successfully. If a requested video is missing its `recording.mp4` or another upload failure occurs, `record` exits non-zero, does not save `.screenci/last-record.json`, and does not print a success URL for unrelated output
- holds a per-project run lock at `.screenci/.record.lock` while recording and uploading. If another `screenci edit` or `screenci export` run is already active, the second run exits non-zero instead of sharing the same `.screenci` output. Stale locks are reclaimed automatically when the pid is gone or the lock is older than the maximum run TTL
- needs no `SCREENCI_SECRET` to run: without one, it records and renders under a local, anonymous trial session with the trial watermark, and prints a link to view the result. Downloads require an account, so the trial run prints the export page URL and a sign-up hint instead of writing files. Recording an anonymous trial agrees to the [Terms](https://screenci.com/legal/tos), which `export` prints before it starts. To upload straight to your organization instead, set `SCREENCI_SECRET` (from the project `.env`, the environment, or by running `init` with it) before exporting
- gives an anonymous trial exactly one recording. Once that first recording is used (or the trial expires), a second `screenci export` stops before it starts and prints a sign-up link instead of recording again, so no time is spent rendering a video that would only be refused. Sign up to keep going; once you have, re-running `export` in the same folder links your account automatically and uploads normally. The trial previews expressive narration and up to 3 languages at once; see [Anonymous Trial](/docs/guides/anonymous-trial) for its full limits and what happens to that content after signup
- uploads only the recordings requested by this run (for example the videos matched by patterns, `--grep`, or `--languages`), with or without `SCREENCI_SECRET`
- exits non-zero unless every requested video rendered and downloaded. If a requested video is missing its `recording.mp4` or another failure occurs, `export` does not save `.screenci/last-record.json` and does not print a success URL for unrelated output
- prints any informational notices returned by the service in cyan after upload (occasional, non-error status messages such as maintenance or timing notes). These never affect the exit code
- new accounts start on the Free plan automatically (no plan selection step). Free exports include a ScreenCI watermark; paid plans remove it and raise export and active-video limits, and unlock multiple languages
@@ -317,7 +322,8 @@ Relevant options:
- `-c, --config <path>`
- `-v, --verbose`
- `--remote` (trigger the project's GitHub Actions recording workflow instead of recording locally, see below)
- `-g, --grep <pattern>` (same filter as Playwright's `--grep`)
- `--remote` (trigger the project's GitHub Actions recording workflow instead of exporting locally, see below)
Important restriction:
@@ -325,21 +331,22 @@ Important restriction:
### `--remote`
`screenci record --remote` does not record on your machine. Instead it asks
`screenci export --remote` does not record on your machine. Instead it asks
ScreenCI to dispatch the project's GitHub Actions recording workflow, so the
recording runs in CI on demand. The project is resolved the same way as every
other command, from `SCREENCI_SECRET` and the config `projectName`, and exits as
soon as the workflow has been triggered.
export runs in CI on demand. The project is resolved the same way as every
other command, from `SCREENCI_SECRET` and the config `projectName`, and the
command exits as soon as the workflow has been triggered (nothing runs or
downloads locally).
```bash
npx screenci record --remote
npx screenci export --remote
```
Pass `--grep` to record only matching videos remotely (the filter
Pass `--grep` to export only matching videos or screenshots remotely (the filter
is forwarded to the workflow's `grep` input):
```bash
npx screenci record --remote --grep "Onboarding"
npx screenci export --remote --grep "Onboarding"
```
This requires connecting the GitHub App to the project first (a one-time setup on
@@ -349,24 +356,27 @@ Without a connected repository the command reports that GitHub is not connected.
Targeted (`--grep`) runs need the workflow to declare a `grep` input; projects
scaffolded by `screenci init` include it.
## `screenci edit`
## `screenci edit [pattern]`
Connects this machine to the ScreenCI editor. This is the live editing
channel: editing in the web editor is locked until a machine you own is
connected, because every web edit is written back into your `.screenci.ts`
sources by this command. While connected, the editor can also trigger a local
`screenci record` of one video and language on demand. The command keeps
running, polling the service for edit and record requests, until you stop it
with Ctrl-C. See [Editor](/docs/guides/editor) for the editing model.
Opens the editing flow for exactly one video. `edit` re-records and uploads
the video's live preview if it is stale, prints the web editor deep link
(`<app>/project/<projectId>/video/<videoId>?editor`), and then stays running
as the code-sync bridge: editing in the web editor is locked until a machine
you own is connected, because every web edit is written back into your
`.screenci.ts` sources by this command. While connected, the editor can also
trigger a local record of the video on demand. The command keeps running,
polling the service for edit and record requests, until you stop it with
Ctrl-C. See [Editor](/docs/editor) for the editing model.
```bash
screenci edit
```
Limit the session to specific videos by passing filter patterns, the same way
`playwright test <pattern>` does. Positional patterns match video titles
(multiple are OR-combined); `--grep` does the same and takes precedence when
both are given:
Pick the video by passing a title pattern, the same way
`playwright test <pattern>` does; `--grep` does the same and takes precedence
when both are given. When the project has more than one video, the pattern
must match exactly one: a pattern that matches zero or several videos makes
`edit` exit with an error that lists the candidate titles.
```bash
screenci edit "Auto-zoom"
@@ -442,8 +452,8 @@ While connected:
- Web edits are applied to your sources as you make them in the editor; the
editor shows a syncing state on your machine's chip while a change is being
written.
- The regular record run lock applies: if another `screenci record` is already
running, the request is reported back to the editor as failed.
- The regular record run lock applies: if another recording run is already
active on the machine, the request is reported back to the editor as failed.
- Stopping the command (Ctrl-C) disconnects the machine; the editor updates
within about fifteen seconds.
@@ -483,12 +493,13 @@ video name and language code. It is where you get the remote `videoId` for
`make-public`, `make-private`, and `delete`.
When this machine has recorded a successful run, `info` also reports that run.
Each `screenci record` upload stores its unique record id in
Each `screenci export` upload stores its unique record id in
`.screenci/last-record.json`; `info` then attaches, **only to the videos
produced by that run**, a per-language `latestRecord` with the run's render
status and its record-pinned URLs, plus a `latestRecordId` on the video. Videos
that were not part of the run are still listed, with `static` URLs only. This is
the command to use in CI to gate on rendering or grab links right after a record.
the command to use in CI to gate on rendering or grab links right after an
export.
```json
{
@@ -573,7 +584,7 @@ Because rendering happens after upload, poll `info` until each `latestRecord`
reaches `finished` (or gate your pipeline on `failed`).
**Not recorded on this machine?** If there is no `.screenci/last-record.json`
(for example a fresh checkout that has never run `screenci record`), `info` does
(for example a fresh checkout that has never run `screenci export`), `info` does
not fail. It simply omits `latestRecordId` and the `latestRecord` fields and
prints the project-wide listing with `static` URLs only.
@@ -689,9 +700,10 @@ You can only delete videos that belong to your own project's organization.
The CLI currently covers:
- project scaffolding with `init`
- project auth setup (handled automatically on first `record`)
- project auth setup (handled automatically on first `export`)
- local iteration with `test`
- final capture and upload with `record`
- single-video editing sessions with `edit`
- finished videos with `export`
- per-run URLs and render status with `info`
- public visibility changes with `make-public` and `make-private`
- permanent video deletion with `delete`
@@ -707,7 +719,8 @@ Manual version pinning is currently handled in the app UI:
These commands support `--config <path>`:
- `test`
- `record`
- `edit`
- `export`
- `info`
- `make-public`
- `make-private`
+4 -4
View File
@@ -91,7 +91,7 @@ YOUR_PRIVATE_SECRET=your_own_app_secret
Common cases:
- `SCREENCI_SECRET` authenticates `screenci record`, `screenci info`, and
- `SCREENCI_SECRET` authenticates `screenci edit`, `screenci export`, `screenci info`, and
public visibility commands.
- Any other variables (for example `YOUR_PRIVATE_SECRET`) are yours to use
inside your own app or test setup. ScreenCI reads them from the env file into
@@ -271,8 +271,8 @@ video.renderOptions({
})
```
The image is uploaded alongside the recording, and drawn in the video
output. It replaces the built-in cursor entirely, so `mouse.style`
The image is uploaded alongside the recording, and drawn in both video and
screenshot output. It replaces the built-in cursor entirely, so `mouse.style`
is ignored when `image` is set. A few things to know:
- Use a **PNG**.
@@ -311,7 +311,7 @@ The web Editor is the source of truth for render and record options. Values
declared in code (per video with `video.renderOptions(...)` and
`video.recordOptions(...)`) are the starting point, and web edits override them.
Omit them entirely to start from the system defaults. See
[Editor](/docs/guides/editor).
[Editor](/docs/editor).
### Example: shared `use` defaults
+12 -8
View File
@@ -86,7 +86,7 @@ video.recordOptions({ fps: 30 })
- [how action parameters are tracked and overridden](#action-parameter-tracking-and-overrides)
- [how to migrate from the removed `editable()` helper](#migrating-from-editable)
<!-- screenci-doc-video:docs/guides/editor -->
<!-- screenci-doc-video:docs/editor -->
## The editor at a glance
@@ -142,12 +142,13 @@ or the source drifted), it stays in the list as failed with **Retry** and
rendering.
Edits that only affect rendering (narration text, overlay files, render
options) preview and export immediately. Edits that change the capture itself
options) preview and export immediately; when the CLI writes one into code it
logs "Applies at render time, no re-record needed" and does not re-record. Edits that change the capture itself
(record options, interaction timings, on-screen text, the language set) are
badged **applies after next recording**: the preview is marked stale until a
recording runs. A connected machine auto-records once it applies such an edit;
otherwise trigger a re-record via CI (see the CI setup guide) or ask a developer
to run `screenci record`. While a connected machine is actively syncing a
to run `screenci edit`. While a connected machine is actively syncing a
video's source, that video's editing controls lock briefly until it finishes.
## Editing in Editor
@@ -191,7 +192,7 @@ The sidebar's **Recording** group collects every way to produce fresh footage:
A status line under the menu tracks the run ("Recording en on laptop...",
"Recording synced."). The regular record run lock applies: if another
`screenci record` is already running on the machine, the request is reported
recording run is already active on the machine, the request is reported
back as failed.
## Editor narration from code
@@ -705,7 +706,10 @@ stamps missing slugs automatically after a recording, allocating numbers from
are never removed). With an editId, the action's stable key IS the slug: edits
keep matching across re-records even after refactors, moved lines, or locator
changes, and codegen locates the call site by the exact slug instead of
heuristics.
heuristics. An action that has not been stamped yet falls back to a readable
identity key built from what was recorded (`delay`, `input click Save`, with
`#2` appended for repeat executions); these keys can drift across re-records,
which is why stamping exists.
The slug is the action's display name on the editor timeline, and it can be
renamed there: the rename is codegen'd by replacing the slug's string literal
@@ -761,7 +765,7 @@ The editor's option panels are codegen'd the same way as timeline edits while
preview and the offline fallback):
- **Render options** (recording size and roundness, background, aspect ratio,
quality, mouse size/style/motion blur, keyboard shortcut display, narration
quality, mouse size/style/motion blur, narration
box styling, shadow, crop) are merged into the video's
`.renderOptions({...})` builder call. The call is appended to the chain when
the video has none yet; existing keys are updated in place and unrelated
@@ -778,7 +782,7 @@ preview and the offline fallback):
sub-object.
Every editor edit is codegen'd: it is written into your `.screenci.ts` sources
through the connected `screenci dev` machine, and fails if no machine is
through the connected `screenci edit` machine, and fails if no machine is
connected. There is no web-side edit store. Uploaded media (narration voices
and recorded audio, cloned-voice samples) is downloaded to local editor files
on the dev machine and referenced from code.
@@ -808,7 +812,7 @@ it, or start it with empty placeholders. The **Languages** section on the Editor
page shows the
current set and lets you add a language; adding one writes it into your
`video.languages([...])` declaration in code (a new `.languages([...])` call is
added when the video has none) through the connected `screenci dev` machine,
added when the video has none) through the connected `screenci edit` machine,
then records:
```ts
-90
View File
@@ -1,90 +0,0 @@
# Keyboard Shortcuts
ScreenCI records keyboard shortcuts pressed with `page.keyboard.press` (and
`locator.press`) and shows them as animated keycap overlays at the bottom of
the rendered video. Each shortcut pops in with a small bounce, holds for a
moment, and fades out.
#### You will learn
- [how shortcuts are recorded](#recording-shortcuts)
- [which shortcuts are shown by default](#default-visibility)
- [how to control visibility per press](#per-press-visibility)
- [how to configure overlays globally](#global-render-options)
## Recording shortcuts
Press keys as you would in Playwright. Modifier combos and single keys are both
recorded:
```ts
import { video } from 'screenci'
video('Command palette', async ({ page }) => {
await page.goto('/')
// A modifier combo: shown as keycaps by default.
await page.keyboard.press('ControlOrMeta+K')
// A single key press: hidden by default.
await page.keyboard.press('Enter')
// Element-targeted presses are recorded too.
await page.getByRole('textbox').press('Shift+Enter')
})
```
`ControlOrMeta` is resolved at record time to the key that was actually
pressed: `Meta` on macOS, `Control` elsewhere, so the rendered keycaps always
match the recording platform.
Presses inside `hide()` are never recorded.
## Default visibility
- Modifier combos (for example `Shift+A` or `ControlOrMeta+K`) are shown by
default.
- Single keys (for example `A` or `Enter`) are hidden by default, since most
single presses are incidental typing rather than a shortcut worth showing.
## Per-press visibility
Override the defaults per press with the `show` option:
```ts
// Show a single key that would be hidden by default.
await page.keyboard.press('F', { show: true })
// Hide a combo that would be shown by default.
await page.keyboard.press('Control+C', { show: false })
```
Each recorded shortcut also appears on the web editor timeline, where it can be
shown or hidden individually with a right click. Editor overrides win over the
`show` option and the global toggles.
## Global render options
Configure the overlays per video under `renderOptions.shortcuts`:
```ts
video.renderOptions({
shortcuts: {
// Show modifier combos. Default: true.
show: true,
// Show single key presses. Default: false.
showSingle: false,
// Keycap appearance: 'light' or 'dark'. Default: 'dark'.
theme: 'dark',
},
})
```
The same options are editable in the web editor's "Keyboard shortcuts" section,
next to the video preview.
Visibility is resolved per shortcut in this order:
1. an editor timeline override for that shortcut
2. the per-press `show` option
3. the global toggle (`show` for combos, `showSingle` for single keys)
+9 -8
View File
@@ -1,6 +1,6 @@
# Languages
ScreenCI supports multiple language versions of a video
ScreenCI supports multiple language versions of a video or screenshot
from a single script. You declare the languages once and ScreenCI records a
separate pass per language, setting the browser locale automatically so a
self-localizing app renders in the right language without extra work from you.
@@ -20,7 +20,8 @@ below); only narration (and per-language browser locales) vary by language.
## One language per plan
Multiple languages are a Business feature. On the Free and Starter plans, your
organization renders a single narration language across all of its videos:
organization renders a single narration language across all of its videos and
screenshots:
- An upload that declares more than one language is blocked.
- Once your organization has rendered one language, an upload in a different
@@ -36,7 +37,7 @@ self-recorded voice. See
[Voices and plans](/docs/guides/narration#voices-and-plans).
Upgrade to Business to render as many languages as you like. When an upload is
blocked, `screenci record` prints the reason and a link back to this section.
blocked, the CLI prints the reason and a link back to this section.
## Add languages
@@ -147,11 +148,11 @@ video
### Recording only some languages
To record (and render) a subset, pass `--languages` to `screenci record`:
To record (and render) a subset, pass `--languages` to `screenci export`:
```bash
screenci record --languages fi
screenci record --languages fi,en
screenci export --languages fi
screenci export --languages fi,en
```
Per-language videos record only the requested languages, so a run never produces
@@ -165,7 +166,7 @@ render this time (rather than treating them as removed from code).
### Variants with `each`
`video.each([...])` produces a **separate video
`video.each([...])` (and `screenshot.each([...])`) produce a **separate video
per variant**, for cases like viewport or theme. Each variant has its own
identity and history. It chains with the per-feature methods:
@@ -219,7 +220,7 @@ language keys, falling back to the implicit `en` default for a plain video.
The **Languages** section on the Editor page lists the current languages and
lets you add one. Adding a language writes it straight into your
`video.languages([...])` declaration in code through the connected
`screenci dev` machine (a new `.languages([...])` call is added when the video
`screenci edit` machine (a new `.languages([...])` call is added when the video
has none), then renders. The edit fails if no dev machine is connected: there
is no web-side language store, so every language lives in code by the time the
next record runs. Editing then continues with the usual guided setup: fill in
+33 -33
View File
@@ -52,18 +52,30 @@ export const docsManifest = [
description:
'Author .screenci.ts files with Playwright-like APIs, ScreenCI narration and camera helpers, and workflow-aware pacing.',
prev: 'docs/manual-setup',
next: 'docs/editor',
},
{
source: 'editor.md',
slug: 'docs/editor',
section: 'Getting Started',
order: 5,
navLabel: 'Editor',
title: 'Editor',
description:
'Edit videos visually in the web app: live preview, timeline cuts and pacing, narration, overlays, and render options. With screenci edit connected, edits are written back into your script, and Export renders the finished video.',
prev: 'docs/video-script-basics',
next: 'docs/ci-setup',
},
{
source: 'ci-setup.md',
slug: 'docs/ci-setup',
section: 'Getting Started',
order: 5,
order: 6,
navLabel: 'CI setup',
title: 'CI Setup',
description:
'Understand the generated GitHub Actions workflow, required secrets, and how to keep CI recordings deterministic.',
prev: 'docs/video-script-basics',
prev: 'docs/editor',
next: 'docs/guides/animated-interactions',
},
{
@@ -76,20 +88,11 @@ export const docsManifest = [
description:
'Understand how ScreenCI instruments the Playwright page so visible actions like clicks, typing, mouse movement, and scrolling are animated.',
prev: 'docs/ci-setup',
next: 'docs/guides/keyboard-shortcuts',
},
{
source: 'keyboard-shortcuts.md',
slug: 'docs/guides/keyboard-shortcuts',
section: 'Guides',
order: 2,
navLabel: 'Keyboard shortcuts',
title: 'Keyboard Shortcuts',
description:
'Record keyboard shortcuts with page.keyboard.press and show them as animated keycap overlays: control visibility per press, globally, or per shortcut in the editor, and pick a light or dark keycap theme.',
prev: 'docs/guides/animated-interactions',
next: 'docs/guides/narration',
},
// The Keyboard Shortcuts guide (docs/guides/keyboard-shortcuts) was removed
// for release along with the shortcut selection surfaces: its source moved
// to docs/removed/keyboard-shortcuts.md at the repo root.
{
source: 'narration.md',
slug: 'docs/guides/narration',
@@ -99,7 +102,7 @@ export const docsManifest = [
title: 'Narration',
description:
'Attach spoken cues to a video, overlap narration with visible UI motion, choose voices, use speech markup, and connect ElevenLabs for custom voices.',
prev: 'docs/guides/keyboard-shortcuts',
prev: 'docs/guides/animated-interactions',
next: 'docs/guides/overlays',
},
// The Values (docs/guides/values), Audio (docs/guides/audio), and Render
@@ -152,20 +155,17 @@ export const docsManifest = [
description:
'Resize, hide, and show the recording frame and narration bubble mid-video with animated transitions, and fade overlays in and out.',
prev: 'docs/guides/camera-and-zooming',
next: 'docs/guides/editor',
next: 'docs/guides/screenshots',
},
// The Screenshots doc (docs/guides/screenshots) was removed for release:
// the screenshot() fixture is unfinished and no longer exported. Its source
// moved to docs/removed/screenshots.md at the repo root.
{
source: 'editor.md',
slug: 'docs/guides/editor',
source: 'screenshots.md',
slug: 'docs/guides/screenshots',
section: 'Guides',
order: 6,
navLabel: 'Editor',
title: 'Editor',
order: 5,
navLabel: 'Screenshots',
title: 'Screenshots',
description:
'Edit videos visually in the web app: live preview, timeline cuts and pacing, narration, overlays, and render options. Edits are written back into your script through a connected screenci edit machine, so code stays the source of truth.',
'Capture branded still screenshots with the screenshot() fixture: crop to a component, set quality and dark mode, and frame the shot on a background with overlays.',
prev: 'docs/guides/overlay-updates',
next: 'docs/guides/public-urls-and-embeds',
},
@@ -173,19 +173,19 @@ export const docsManifest = [
source: 'public-urls-and-embeds.md',
slug: 'docs/guides/public-urls-and-embeds',
section: 'Guides',
order: 7,
order: 6,
navLabel: 'Public URLs and embeds',
title: 'Public URLs and Embeds',
description:
'Enable public delivery for a video, understand stable language-specific URLs, and embed ScreenCI outputs in other sites.',
prev: 'docs/guides/editor',
prev: 'docs/guides/screenshots',
next: 'docs/guides/redact',
},
{
source: 'redact.md',
slug: 'docs/guides/redact',
section: 'Guides',
order: 8,
order: 7,
navLabel: 'Redact sensitive content',
title: 'Redact Sensitive Content',
description:
@@ -197,7 +197,7 @@ export const docsManifest = [
source: 'screen-audio.md',
slug: 'docs/guides/screen-audio',
section: 'Guides',
order: 9,
order: 8,
navLabel: 'Screen audio',
title: 'Screen Audio',
description:
@@ -209,7 +209,7 @@ export const docsManifest = [
source: 'update-screenci.mdx',
slug: 'docs/guides/update-screenci',
section: 'Guides',
order: 10,
order: 9,
navLabel: 'Update ScreenCI',
title: 'Update ScreenCI',
description:
@@ -221,7 +221,7 @@ export const docsManifest = [
source: 'version-history.md',
slug: 'docs/guides/version-history',
section: 'Guides',
order: 11,
order: 10,
navLabel: 'Version history',
title: 'Version History',
description:
@@ -233,7 +233,7 @@ export const docsManifest = [
source: 'organisation.md',
slug: 'docs/guides/organisation',
section: 'Guides',
order: 12,
order: 11,
navLabel: 'Organisation & SSO',
title: 'Organisation & SSO',
description:
@@ -245,7 +245,7 @@ export const docsManifest = [
source: 'anonymous-trial.md',
slug: 'docs/guides/anonymous-trial',
section: 'Guides',
order: 13,
order: 12,
navLabel: 'Anonymous trial',
title: 'Anonymous Trial',
description:
+30 -29
View File
@@ -230,69 +230,70 @@ yarn screenci test --ui
</TabItem>
</Tabs>
## Record and preview
## Record and refine in the editor
When the script is green, record the videos in the `screenci/recordings/`
directory:
When the script is green, open the video in the editing flow:
<Tabs syncKey="package-manager">
<TabItem label="npm">
```bash
npx screenci record
npx screenci edit
```
</TabItem>
<TabItem label="pnpm">
```bash
pnpm exec screenci record
pnpm exec screenci edit
```
</TabItem>
<TabItem label="yarn">
```bash
yarn screenci record
yarn screenci edit
```
</TabItem>
</Tabs>
`screenci record` needs no account or secret to work: without one, it uploads
under a local, anonymous trial session, and prints a link to view the result.
Open that link, then sign up to keep the video and get a shareable URL. See
[Anonymous Trial](/docs/guides/anonymous-trial) for what the trial allows
(including a preview of expressive narration and multiple languages) and its
limits.
`edit` records the video (if its live preview is stale), uploads the preview,
and prints a link to the web editor. Keep it running: it connects your machine
as the code-sync bridge, which unlocks editing in the browser: narration text
and voices, overlays, cuts and pacing on the timeline, and render options.
Every edit is written back into your `.screenci.ts` script through the
connected machine, so the code always matches what you see. The live preview
is free to iterate on: record, watch, adjust, and record again without
spending anything. See [Editor](/docs/editor) for the full tour. If the
project has several videos, pass a title pattern
(`npx screenci edit "Onboarding"`) to pick one.
For an account you already have, copy `SCREENCI_SECRET` from your secrets page
into `screenci/.env` (the same value you set for CI runs) so recordings upload
directly to your organization. The upload contains the raw recording, not your
source code.
By default `record` refreshes the video's **live preview** in the web editor,
so you can iterate for free: record, watch the preview, adjust the script, and
record again without spending anything.
## Refine in the editor
Open the video in the web app and run `screenci edit` in your project to
connect your machine. That unlocks editing in the browser: narration text and
voices, overlays, cuts and pacing on the timeline, and render options. Every
edit is written back into your `.screenci.ts` script through the connected
machine, so the code always matches what you see. See
[Editor](/docs/guides/editor) for the full tour.
## Export the final video
When the preview looks right, export a finished video with the **Export**
button in the editor, or from the CLI:
When the preview looks right, export the finished videos:
```bash
npx screenci record --export
npx screenci export
```
`screenci export` needs no account or secret to work: without one, it records
and renders under a local, anonymous trial session (with the trial watermark)
and prints a link to view the result; downloads require an account, so it
prints the export page URL and a sign-up hint instead of writing files. Open
that link, then sign up to keep the video and get a shareable URL. See
[Anonymous Trial](/docs/guides/anonymous-trial) for what the trial allows
(including a preview of expressive narration and multiple languages) and its
limits.
With an account, `export` re-records anything whose sources changed, renders,
waits, and downloads the finished files into `./exports/`.
Exporting renders the video in the service and spends export minutes. Free
exports include a ScreenCI watermark. Upgrading removes it (and raises your
export and active-video limits).
@@ -305,7 +306,7 @@ It should look something like this:
- [Video Script Basics](/docs/video-script-basics) to learn how video scripts
work, including how to generate a first draft with codegen.
- [Editor](/docs/guides/editor) to edit the video visually in the browser with
- [Editor](/docs/editor) to edit the video visually in the browser with
`screenci edit`.
- [CI Setup](/docs/ci-setup) to configure recording in GitHub Actions and keep
CI runs predictable.
+2 -2
View File
@@ -558,7 +558,7 @@ never shows the stored key again, only whether one is set, and every render
(from the CLI or the app) uses it. You do not set an ElevenLabs key locally.
Without a key, a video that uses an ElevenLabs or custom voice cannot render.
`screenci record` fails that video at record time: its render is marked failed
`screenci export` fails that video at record time: its render is marked failed
right away (rather than being queued only to die during synthesis), the CLI
prints an error with a link to the Secrets page, and the command exits non-zero.
Other videos in the same run are unaffected. Add your key on the Secrets page and
@@ -795,4 +795,4 @@ script):
a cloned voice, the web equivalent of
[cloning from an audio sample](#clone-a-voice-from-an-audio-sample).
See [Editor](/docs/guides/editor).
See [Editor](/docs/editor).
+7 -2
View File
@@ -98,7 +98,9 @@ video.overlays({
`video.overlays({...})` returns a builder you call with the usual
`(title, body)` arguments. Each key becomes a callable overlay controller,
delivered to the body through the injected `overlays` fixture.
delivered to the body through the injected `overlays` fixture. The same pattern
works for screenshots:
`screenshot.overlays({...})('Title', async ({ page, crop, overlays }) => {...})`.
You can also declare overlay names alone with a bare array and upload the files
plus display options on the Editor page instead of keeping them in the
@@ -589,6 +591,7 @@ video.overlays({
- The overlay keeps its placement: a `pinToScreen` overlay stays fixed in screen space, a burned overlay still moves and scales with the camera during zoom. `overMouse` only changes its stacking order relative to the cursor.
- Overlapping `overMouse` overlays each draw above the cursor.
- It has no effect on screenshots, whose cursor is hidden by default (see `renderOptions.screenshot.mouse.show`).
### Positioning over a live element
@@ -653,7 +656,9 @@ same element box rasterize only once.
Add [`animate: true`](#animated-overlays) and the ring plays its CSS animation
back in the video while the page keeps being driven underneath. Here is that same
margin ring, pulsing around a live element:
margin ring, pulsing around a live element (the
[Screenshots guide](/docs/guides/screenshots#highlight-a-locator) shows the
still version):
<!-- screenci-doc-video:docs/guides/overlays -->
+13 -14
View File
@@ -7,21 +7,20 @@ script and the video regenerates.
## How it works
Record with the CLI, refine in the web editor, then export and deliver the
Record and refine with the CLI and the web editor, then export and deliver the
final video where your team needs it.
<!-- screenci-image:VideoLifecycle -->
- **Record.** `screenci record` captures the flow on your machine and uploads
the recording. By default this only refreshes the video's live preview in
the web editor, so you can iterate for free.
- **Refine.** The [Editor](/docs/guides/editor) shows a live preview and a
timeline. With `screenci edit` running, every edit you make in the browser
(narration, overlays, cuts, timings, render options) is written back into
your script, so code stays the source of truth.
- **Export.** When the preview looks right, export (the Export button, or
`screenci record --export`) to render the finished video, then serve it
from a stable public URL.
- **Record and refine.** `screenci edit` captures the flow on your machine,
uploads the live preview, and opens the video in the [Editor](/docs/editor):
a live preview and a timeline. While `edit` stays running, every change you
make in the browser (narration, overlays, cuts, timings, render options) is
written back into your script, so code stays the source of truth.
- **Export.** When the preview looks right, run `screenci export` (or use the
Export button) to render the finished videos and download them, then serve
them from a stable public URL. `export` re-records only the videos whose
sources changed, so it is also the command CI runs.
The CLI runs everything that touches your app locally, so the only thing that
leaves your machine is the raw recording plus timing data, not your source code.
@@ -36,11 +35,11 @@ Pick the path that fits how you work.
scaffolds ScreenCI, authors a video for the flow you describe, and records it,
with you mostly reviewing the result.
- **[Manual setup & first video](/docs/manual-setup).** Scaffold ScreenCI
yourself, write the `.screenci.ts` script by hand, run it locally, and record the
final video.
yourself, write the `.screenci.ts` script by hand, run it locally, and export
the final video.
Either way, once a video exists you can dig into the rest of the docs to refine
it: [Editor](/docs/guides/editor),
it: [Editor](/docs/editor),
[Video Script Basics](/docs/video-script-basics),
[Camera and Zooming](/docs/guides/camera-and-zooming),
[Narration](/docs/guides/narration), and
+1 -1
View File
@@ -168,7 +168,7 @@ Error responses:
Every media endpoint has a record-pinned form that adds a `records/<recordId>`
segment to the path. The static URL (no `records/` segment) serves the
**currently selected** version. The pinned URL serves the render produced by that
specific `screenci record` run.
specific `screenci export` run.
```text
GET /public/:id/:language/video # latest selected version
+1 -1
View File
@@ -84,7 +84,7 @@ Manual selection is currently done in the app, not the CLI.
Alongside the stable URLs above, every media URL has a record-pinned form that
adds a `records/<recordId>` segment to the path, pinning it to a specific
`screenci record` run:
`screenci export` run:
```text
https://api.screenci.com/public/<videoId>/records/<recordId>/<language>/video
+1 -1
View File
@@ -78,7 +78,7 @@ works with no extra configuration. Install one with your package manager, e.g.
To force a specific binary, set `SCREENCI_FFMPEG_PATH`, which always wins:
```bash
SCREENCI_FFMPEG_PATH=/usr/bin/ffmpeg pnpm screenci record
SCREENCI_FFMPEG_PATH=/usr/bin/ffmpeg pnpm screenci export
```
Verify a binary has the input with `ffmpeg -hide_banner -formats | grep pulse`.
+318
View File
@@ -0,0 +1,318 @@
# Screenshots
Alongside videos, ScreenCI can produce **still screenshots**. You author them
with the same Playwright-like API you already use for videos and drive the page
to the state you want. Three things make them worth reaching for:
- **Crop** to any component or region, so the shot is exactly the part that
matters, optionally framed on your background with no manual editing.
- **Overlays** (badges, callouts, annotations) layered on with the same assets
as your videos, so stills and videos share one visual language.
- **Delivery from a static, CDN-backed URL** you can drop straight into a page,
README, or social card. It stays fast and cacheable, and updates when you
re-render.
- **Automatic updates** when the product changes: re-record locally, in CI, or
from the ScreenCI UI, and every hosted URL serves the latest accepted shot.
- **Web editable** like videos: every render option (crop padding, background,
frame, overlays) is adjustable later in Editor in the browser, with no
re-recording needed.
A screenshot is captured directly from the page (no video is recorded), then
served from that hosted URL with the same versioning and Editor editing as
videos. By default the output is the bare crop. Add a `margin` and/or an output
`aspectRatio` and the shot is composited on your configured background with a
rounded frame and shadow (see [Render options](#render-options)).
## Two ways to capture
**1. A standalone `screenshot()` test** drives the page and captures the final
state (no video recorded):
```ts
import { screenshot } from 'screenci'
screenshot('Dashboard', async ({ page }) => {
await page.goto('https://app.example.com/dashboard')
})
```
**2. `page.screenshot()` inside a `video()`** grabs a still of a moment that also
appears in the video. Each call becomes its own screenshot recording named by the
`name` you pass:
```ts
import { video } from 'screenci'
video('Product demo', async ({ page }) => {
await page.goto('https://app.example.com/dashboard')
await page.screenshot({ name: 'Dashboard' }) // -> screenshot named "Dashboard"
})
```
Both produce the same still, framed and hosted identically. The rest of this
guide applies to either. The sections below detail each.
Screenshots live in the same `*.screenci.ts` files as videos (the `recordingDir`), so
a file can contain any mix of `video()` and `screenshot()` calls. A `screenshot()`
body runs just like a video body; when it returns, the final page state is
captured. Narration, audio, and camera motion do not apply to a still and are
ignored. `hide()` is likewise a no-op: a still keeps only the final frame, so
there is no timeline to cut a hidden section from (the wrapped setup still runs,
and screenci warns if you use it). Because only the final frame is kept, cursor
moves are instant during a screenshot (the cursor still lands at its target);
the smooth gliding animation is for videos.
#### You will learn
- [how to crop to a component or a region](#cropping)
- [how to add overlays](#overlays)
- [how to set quality and dark mode](#quality-and-appearance)
- [how to set the background, frame, and output format](#render-options)
## Cropping
The `screenshot()` fixture provides a `crop` argument. Call it to frame a single
component or an explicit region. The crop is applied by the renderer, which
places the cropped region (plus any `padding`) on the canvas (framed on the
background once you add a `margin` or output `aspectRatio`).
```ts
import { screenshot } from 'screenci'
screenshot('Revenue card', async ({ page, crop }) => {
await page.goto('https://app.example.com/dashboard')
// Crop to a component, with 48 px of breathing room around it.
await crop(page.getByTestId('revenue-card'), { padding: 48 })
})
```
`crop` also accepts an explicit region in CSS px of the recording viewport:
```ts
await crop({ x: 128, y: 160, width: 1024, height: 768 })
```
Inside a `video()`, pass the same crop to `page.screenshot()` via its `crop`
option, which takes either a locator or an explicit region:
```ts
import { video } from 'screenci'
video('Product demo', async ({ page }) => {
await page.goto('https://app.example.com/dashboard')
// Crop the still to a component...
await page.screenshot({
name: 'Revenue card',
crop: page.getByTestId('revenue-card'),
})
// ...or to an explicit region in CSS px of the recording viewport.
await page.screenshot({
name: 'Chart region',
crop: { x: 128, y: 160, width: 1024, height: 768 },
})
})
```
### Padding
`padding` is the breathing room around the target, in CSS px. Pass a single number
to pad every side equally, or an object for **uneven padding** (omitted sides
default to `0`):
```ts
// 24 px on every side except a roomier bottom for a caption.
await crop(page.getByTestId('chart'), {
padding: { top: 24, right: 24, bottom: 64, left: 24 },
})
```
The crop comes only from your `crop()` call (or `page.screenshot({ crop })` inside
a video). It is recorded as `renderOptions.screenshot.crop` and edited in Editor,
not set in config. How it edits depends on the target:
- A **locator** crop is bound to the element: its box is locked in Editor (it
re-resolves from the locator every time you re-render), while the `padding`
around it stays adjustable.
- An **explicit region** crop has no element to track, so it is a free rectangle
you can drag and resize in Editor.
To force a fixed output shape (a square or social card), set the output
`aspectRatio` in [render options](#render-options) rather than reshaping the crop.
## Overlays
Overlays work exactly as they do for videos, so a screenshot and a video from the
same project share one visual language. Declare overlays with
[`screenshot.overlays(...)`](/docs/guides/overlays) for badges and annotations,
and start them in the screenshot body.
```ts
import { screenshot } from 'screenci'
screenshot.overlays({
newBadge: { path: '../assets/new-badge.png', x: 1382, y: 65, width: 384 },
})('Dashboard', async ({ page, crop, overlays }) => {
await page.goto('https://app.example.com/dashboard')
// In a screenshot, start an overlay and leave it open: it stays in the still.
await overlays.newBadge.start()
await crop(page.getByTestId('revenue-card'), { padding: 48 })
})
```
A still has no timeline, so an overlay you `start()` is simply shown in the image,
with no matching `end()` needed. The equivalent `video()` must close the overlay
before the recording stops:
```ts
video.overlays({
newBadge: { path: '../assets/new-badge.png', x: 1382, y: 65, width: 384 },
})('Dashboard', async ({ page, overlays }) => {
await page.goto('https://app.example.com/dashboard')
// In a video, an overlay you start() must be ended.
await overlays.newBadge.start()
await page.getByTestId('revenue-card').hover()
await overlays.newBadge.end()
})
```
### Highlight a locator
A common still is one element framed by a ring. Give a
[programmatic overlay](/docs/guides/overlays#positioning-over-a-live-element)
an `over` locator and a `margin` (CSS px): screenci sizes the ring to the
element's box plus that margin and lands it exactly around the element. In a
still you `start()` it and leave it open.
<!-- screenci-doc-screenshot:docs/guides/screenshots -->
The [Overlays guide](/docs/guides/overlays#positioning-over-a-live-element)
shows the same ring animated in a video.
## Cursor
A still does not show the mouse cursor by default, so polished product shots stay
clean. When the shot is meant to demonstrate an interaction (a hover or a click
target), turn the cursor on with `renderOptions.screenshot.mouse.show`. It is
drawn at the cursor's final position, the same spot the cursor lands after your
last `move`/`click`/`hover` in the body.
```ts
import { screenshot } from 'screenci'
screenshot('Hover state', async ({ page }) => {
await page.goto('https://app.example.com/dashboard')
// Land the cursor where you want it shown.
await page.getByRole('button', { name: 'Upgrade' }).hover()
})
screenshot.renderOptions({
screenshot: {
mouse: { show: true }, // default is false (no cursor)
},
})
```
The cursor reuses the same assets and styling as the video cursor: its colour
comes from `renderOptions.mouse.style` (`'white'` or `'black'`) and its size from
`renderOptions.mouse.size` (a fraction of the output height). Setting `show: true`
has no effect when the body never moved the cursor (there is no position to draw),
so a still that never touches the mouse never shows one. Like every render option,
`show` is editable later in Editor without re-recording.
## Quality and appearance
The viewport comes from `recordOptions.aspectRatio` and `recordOptions.quality`,
the same as videos. Screenshots capture at `recordOptions.deviceScaleFactor`,
which **defaults to `2`** so stills are crisp; lower it to `1` for smaller files,
or raise it for extra-high-DPI captures. Any Playwright `use` option (such as
`colorScheme: 'dark'`) is honored too.
```ts
screenshot.use({ colorScheme: 'dark' })
screenshot.recordOptions({
aspectRatio: '16:9',
quality: '1440p',
deviceScaleFactor: 2, // the default; set to 1 for smaller files
})
```
A still captured inside a `video()` (via `page.screenshot()`) uses the video's
viewport resolution (the video pipeline does not upscale device pixels), so record
at a higher `recordOptions.quality` for crisp stills, or use a standalone
`screenshot()` test when you need a higher DPI than the video.
## Animations and speed
A still keeps only the final frame, so screenci does no animating to reach it.
Its own interaction animations (the cursor glide, the typing effect, the click
press, and the pacing pauses between actions) are all made instant for
screenshots, so a still runs as fast as the raw page interactions rather than
paying for motion that is never seen. Videos keep their pacing.
screenci also disables the **app's own** CSS animations and transitions while the
body drives the page. This is controlled by `recordOptions.disableAnimations`,
which **defaults to `true` for screenshots** and `false` for video (where motion
is usually the point). Override it either way: set `false` on a screenshot that
needs a mid-animation state, or `true` on a video to strip its animations.
```ts
screenshot.recordOptions({
disableAnimations: false, // keep the app's animations while capturing
})
```
## Render options
Every visual choice is a render option, editable later in Editor. The
configurable screenshot-only options (`format`, `margin`, `aspectRatio`) live
under `renderOptions.screenshot`; the background, frame roundness, and shadow are
shared with video and stay under `renderOptions.output` and
`renderOptions.recording`. The comments below are the reference for each field.
There is no resolution preset for screenshots: the pixel size comes from the
captured crop scaled by the capture device pixel density
(`recordOptions.deviceScaleFactor`). Raise that for sharper stills. The crop is
not configurable here (it is recorded from `crop()` / `page.screenshot({ crop })`).
The background (and the frame shadow and rounded corners) appear only when there
is canvas area around the shot for them to fill, which is created by `margin`
and/or an explicit `aspectRatio`. With neither, the output is the bare crop and
the `output`/`recording` styling below has nowhere to render.
```ts
screenshot.renderOptions({
screenshot: {
// Gap between the framed shot and the canvas edge, in CSS px. A value > 0
// creates a background gutter (and gives the shadow and rounded corners room
// to render). Defaults to 0: the canvas hugs the shot, no background.
margin: 64,
// Output canvas aspect ratio. 'auto' (the default) hugs the shot plus the
// margin. An explicit ratio ('16:9', '1:1', '9:16', ...) centers the shot in
// that canvas and fills the surround with the background, for social cards.
aspectRatio: '1:1',
// PNG by default (lossless). JPEG is smaller for photo-heavy shots; its
// `quality` (1-100, default 90) is the compression level. Low values are
// allowed if you want a smaller file.
format: { type: 'jpeg', quality: 82 },
// Show the cursor at its final recorded position. Defaults to false (no
// cursor). Colour/size come from `mouse.style` / `mouse.size` below.
mouse: { show: true },
},
output: {
// Anything behind the framed shot: a CSS color, gradient, or image.
background: {
backgroundCss:
'linear-gradient(135deg, #0f172a 0%, #1e293b 60%, #0f3460 100%)',
},
},
recording: {
// Corner radius of the framed shot, as a fraction (0-1) of its shorter side.
roundness: 0.04,
// Drop shadow behind the frame (any CSS `drop-shadow(...)` filter).
dropShadow: 'drop-shadow(0 12px 32px rgba(0,0,0,0.55))',
},
})
```
+5 -6
View File
@@ -169,7 +169,7 @@ Run the normal checks:
```bash
npx screenci test
npx screenci record
npx screenci export
```
</TabItem>
@@ -177,7 +177,7 @@ npx screenci record
```bash
pnpm exec screenci test
pnpm exec screenci record
pnpm exec screenci export
```
</TabItem>
@@ -185,15 +185,14 @@ pnpm exec screenci record
```bash
yarn screenci test
yarn screenci record
yarn screenci export
```
</TabItem>
</Tabs>
A plain `record` refreshes the live preview only; to verify a finished video
end to end (including public URLs), export one with
`screenci record --export`.
`screenci export` verifies a finished video end to end (including public
URLs): it re-records what changed, renders, and downloads the outputs.
Focus on behavioral verification, not only compilation:
+3 -3
View File
@@ -1,8 +1,8 @@
# Version History
Every export of a video is kept as a **version** (versions are created on
export, not on every `screenci record`, which only refreshes the live
preview). One version is always the **selected** one: it is what a public URL
Every export of a video (or screenshot) is kept as a **version** (versions are created on
export, not on preview recordings from `screenci edit`, which only refresh the
live preview). One version is always the **selected** one: it is what a public URL
serves and what embeds display. Version history lets you keep older renders
around, compare them, and roll back by selecting a previous one, without
re-recording.
+3 -3
View File
@@ -36,9 +36,9 @@ ScreenCI video yet. To turn it into one:
- change `test(...)` to `video.narration(...)(...)`
- add narration through `video.narration({...})` (see [Core ScreenCI APIs](#core-screenci-apis))
Then follow the usual flow: `screenci test` to verify, `screenci record` to
refresh the live preview, and `screenci record --export` for a finished
video. If you only
Then follow the usual flow: `screenci test` to verify, `screenci edit` to
record it and refine it in the web editor, and `screenci export` for a
finished video. If you only
have a deployed URL and want this automated, point a coding agent at it with the
`playwright-cli` skill, see [Agent integration](/docs/agent-integration).
@@ -0,0 +1,21 @@
import type { Locator } from '@playwright/test'
import { screenshot } from 'screenci'
// Rings a single element on the marketing site, then captures a branded still.
// The Overlays guide animates the same example.
screenshot.overlays({
// A full .html page that fills its box; `over` sizes it to the element.
ring: (target: Locator) => ({
path: './assets/ring.html',
over: target,
margin: 6,
}),
})('Locator highlight', async ({ page, overlays }) => {
await page.goto('https://screenci.com/')
const cta = page.getByRole('link', { name: 'View Documentation' })
await cta.scrollIntoViewIfNeeded()
// In a still, start the overlay and leave it open: it stays in the image.
await overlays.ring(cta).start()
})
+10 -4
View File
@@ -35,9 +35,15 @@ export const docsVideoRegistry = {
sourcePath:
'screenci/docs/video-sources/locator-highlight-animated.screenci.ts',
},
// The Screenshots guide and its locator-highlight still were removed for
// release (the screenshot() fixture is unfinished); the demo source moved to
// docs/removed/video-sources/ at the repo root.
// The locator-highlight still in the Screenshots guide. Published as a
// screenshot recording.
'docs/guides/screenshots': {
publicId: 'kh7405zt507ht1qr1mg5hgxxpx892amb',
kind: 'screenshot',
alt: 'A marketing-site link highlighted by a pink ring with margin around it, framed as a branded still',
sourcePath:
'screenci/docs/video-sources/locator-highlight-still.screenci.ts',
},
// The narrated walkthroughs below are authored but not yet recorded: they have
// no publicId, so the page injects an invisible placeholder until the video is
// recorded and published, then a publicId is added here (see videos.ts docs).
@@ -55,7 +61,7 @@ export const docsVideoRegistry = {
sourcePath: 'screenci/docs/video-sources/languages.screenci.ts',
showSource: false,
},
'docs/guides/editor': {
'docs/editor': {
sourcePath: 'screenci/docs/video-sources/editor.screenci.ts',
showSource: false,
},
+4 -3
View File
@@ -16,14 +16,15 @@ describe('public api surface', () => {
'hideNarration',
'hideRecording',
'modelTypes',
// moveNarration, resizeNarration, setBackground, and screenshot are
// hidden for release (unfinished features); their exports are commented
// out in index.ts.
// moveNarration, resizeNarration, and setBackground are hidden for
// release (unfinished features); their exports are commented out in
// index.ts.
'overlayRect',
'redact',
'resetZoom',
'resizeRecording',
'resolveLocaleForLanguage',
'screenshot',
'showNarration',
'showRecording',
'speed',
+5 -11
View File
@@ -6,11 +6,8 @@ export type {
RecordUploadPolicy,
RecordOptions,
RenderOptions,
// Hidden for release: the screenshots feature is unfinished. Re-enable by
// uncommenting these exports (and the ones marked below). The removed docs
// live in docs/removed/ at the repo root.
// ScreenshotOutputFormat,
// ScreenshotRenderOptions,
ScreenshotOutputFormat,
ScreenshotRenderOptions,
ScreenCIConfig,
ExtendedScreenCIConfig,
AutoZoomOptions,
@@ -37,16 +34,13 @@ export { defineConfig } from './src/config.js'
// Re-export video fixture and narration cue
export { video } from './src/video.js'
// Hidden for release: the screenshots feature is unfinished. Re-enable by
// uncommenting. Docs moved to docs/removed/screenshots.md at the repo root.
// export { screenshot } from './src/screenshot.js'
export { screenshot } from './src/screenshot.js'
export type {
ClipTarget,
ClipRegion,
ClipOptions,
// Hidden for release together with screenshot() above.
// ScreenshotClip,
// ScreenshotClipRecord,
ScreenshotClip,
ScreenshotClipRecord,
ResolvedClipPadding,
} from './src/clip.js'
export type {
+12 -11
View File
@@ -28,8 +28,8 @@ npx screenci test
# run a subset with normal Playwright filters
npx screenci test recordings/signup.screenci.ts --grep "fills billing details"
# only record after tests pass
npx screenci record
# only export after tests pass
npx screenci export
```
`test` forwards normal `playwright test` arguments and still injects the resolved `screenci.config.ts`. `--config`/`-c` and `--verbose`/`-v` are reserved for the ScreenCI CLI, not forwarded to Playwright.
@@ -112,27 +112,28 @@ await autoZoom(async () => {
## Connecting to an Account (optional)
`record` needs no account: without a `SCREENCI_SECRET` it uploads under a local, anonymous trial session and prints a link to view the result. Mention this and keep going.
`export` needs no account: without a `SCREENCI_SECRET` it records and renders under a local, anonymous trial session and prints a link to view the result (downloads require an account). Mention this and keep going.
To upload straight to an existing organization, get `SCREENCI_SECRET` into `screenci/.env` before the final `record` (it does not block authoring or testing):
To upload straight to an existing organization, get `SCREENCI_SECRET` into `screenci/.env` before the final `export` (it does not block authoring or testing):
1. **Pass it to init:** `npm init screenci@latest <SCREENCI_SECRET> -- --yes` writes it into `screenci/.env`.
2. **Secrets page:** ask the user to copy `SCREENCI_SECRET` from their secrets page into `screenci/.env`. The org secret is shared across projects. Keep building and testing while they do it; only `record` needs it.
2. **Secrets page:** ask the user to copy `SCREENCI_SECRET` from their secrets page into `screenci/.env`. The org secret is shared across projects. Keep building and testing while they do it; only `export` needs it.
Renders without an account, and renders on the free tier, include a ScreenCI watermark. Do not add a separate upgrade upsell after `record`; report the result URL unless the user asks about plans or watermark removal.
Renders without an account, and renders on the free tier, include a ScreenCI watermark. Do not add a separate upgrade upsell after `export`; report the result URL unless the user asks about plans or watermark removal.
## Recording Workflow
## Export Workflow
1. Add or edit `.screenci.ts` files in `recordings/` (remove `example.screenci.ts` if creating new videos).
2. Run `npx screenci test` until it passes. Fix selectors/flow/narration and rerun until green.
3. Run `npx screenci record` yourself once tests pass. Do not stop and ask the user to record. It uploads immediately, with or without `SCREENCI_SECRET`.
4. ScreenCI writes `.screenci/<video-name>/recording.mp4` and `data.json` per video.
5. Report the URL `record` printed (starts with the app's domain, e.g. `https://app.screenci.com/record/...`) so the user can open it. Without a `SCREENCI_SECRET`, this is also how they view and claim the anonymous trial recording.
3. Run `npx screenci export` yourself once tests pass. Do not stop and ask the user to export. It records what changed, renders, waits, and downloads into `./exports/`, with or without `SCREENCI_SECRET`.
4. ScreenCI writes `.screenci/<video-name>/recording.mp4` and `data.json` per re-recorded video.
5. Report the URL `export` printed (starts with the app's domain, e.g. `https://app.screenci.com/export/...`) so the user can open it. Without a `SCREENCI_SECRET`, this is also how they view and claim the anonymous trial recording.
6. To refine one video interactively, run `npx screenci edit "<title>"`: it records the live preview if stale, prints the web editor link, and stays connected so browser edits are written back into the script.
`screenci init` (or `npm init screenci`) scaffolds a new project and fails on purpose if one already exists (`screenci/ already exists`). That is expected: keep working with the existing project, do not delete it to re-init.
## Specific Tasks
- **Recording videos** [references/record.md](references/record.md)
- **Exporting videos** [references/export.md](references/export.md)
</content>
</invoke>
+61
View File
@@ -0,0 +1,61 @@
# `screenci export`
Use `screenci export` to produce finished ScreenCI videos from `.screenci.ts` scripts.
Assume the ScreenCI project is already initialized. Add new video scripts under `recordings/`.
If you are creating new videos, remove the starter `recordings/example.screenci.ts` file.
## Commands
```bash
npx screenci export
npx screenci export "Video title"
npx screenci export -c screenci.config.ts
```
## What It Does
- Re-records only the videos whose sources changed since the last upload (local Playwright), saving output under `.screenci/<video-name>/` (`recording.mp4` and `data.json`)
- Dispatches server-side renders for up-to-date videos without re-recording them
- Waits for the renders to finish (polls every 5 seconds, up to 30 minutes)
- Downloads the outputs into `./exports/` (or `-o <dir>`), named `<title>.<lang>.mp4` (screenshots `.png`)
- Exits `0` only when every requested video rendered and downloaded
Positional arguments are title patterns; no patterns exports every video in every language. Other flags: `-g/--grep`, `--languages fi,en`, `--force` (re-record everything), `--remote` (dispatch the project's GitHub Actions workflow instead of running locally).
## Connecting to an Account (optional)
`export` needs no account or setup step: without a `SCREENCI_SECRET`, it records and renders under a local, anonymous trial session (with the trial watermark) and prints a link to view the result. Downloads require an account, so a trial run prints the export page URL and a sign-up hint instead of writing files.
To upload straight to an existing organization instead, get `SCREENCI_SECRET` into `screenci/.env` before the final export (it does not block authoring or testing):
- Pass it to `init` as an argument: `npm init screenci@latest <SCREENCI_SECRET> -- --yes`.
- Or ask the user to copy `SCREENCI_SECRET` from their secrets page into `screenci/.env`. The org secret is shared across projects.
## Runtime Behavior
- Recording runs with local Playwright.
- `export` uploads every successful recording, with or without `SCREENCI_SECRET` set.
- Without an account, or on the free tier, renders include a ScreenCI watermark.
- After a successful `export`, report the URL it printed (starts with the app's domain, e.g. `https://app.screenci.com/export/...`) back to the user so they can open it. Without a `SCREENCI_SECRET`, this is also how they view and claim the anonymous trial recording.
## Recommended Workflow
```bash
# first verify the flow
npx screenci test
# then export
npx screenci export
```
## Workflow
Always run `npx screenci test` until it passes before running `npx screenci export`. Fix failures and rerun until green.
```bash
npx screenci test # verify selectors, flow, and narration
npx screenci export # record, render, and download the finished videos
```
To refine a single video interactively instead of exporting, run `npx screenci edit "<title>"`: it records the live preview if stale, prints the web editor link, and stays connected so browser edits are written back into the script.
+3 -3
View File
@@ -37,19 +37,19 @@ Under `--no-react`, no `.tsx` files are scaffolded: the screenshot example still
## Notes
- `init` can be run at any time, but it is non-destructive and will not re-initialize an existing project. If the project is already initialized (a `screenci/` directory already exists), it fails on purpose and exits with an error like `screenci/ already exists`. That is expected. Do not delete the existing project to force a re-init: continue working with the project that is already there.
- No account or setup token is needed. `record` (see below) uploads under a local, anonymous trial session with no connection step at all.
- No account or setup token is needed. `export` (see below) uploads under a local, anonymous trial session with no connection step at all.
- If the user already has a `SCREENCI_SECRET` from an existing account, pass it as init's first positional argument and init writes it into `screenci/.env`, so recordings upload straight to their organization instead of an anonymous trial.
- Prefer `--yes` for non-interactive setup. Without it, the command prompts for setup choices and defaults the project name to the current directory name when none is provided. A positional that looks like a `SCREENCI_SECRET` is treated as the secret, not the project name.
- The name is used as the ScreenCI project display name. Files are always created in the current directory.
- `--yes` accepts the defaults.
- `--agent <name>` is passed to the selected skills install command.
- `--verbose` shows more setup output.
- `record` uses local Playwright and uploads with or without `SCREENCI_SECRET` set.
- `export` uses local Playwright and uploads with or without `SCREENCI_SECRET` set.
## Typical Flow
```bash
npm init screenci@latest -- --yes # scaffold, no account needed
npx screenci test # verify the video works
npx screenci record # capture the final recording and upload
npx screenci export # record, render, and download the finished video
```
-56
View File
@@ -1,56 +0,0 @@
# `screenci record`
Use `screenci record` to capture ScreenCI videos from `.screenci.ts` scripts.
Assume the ScreenCI project is already initialized. Add new video scripts under `recordings/`.
If you are creating new videos, remove the starter `recordings/example.screenci.ts` file.
## Commands
```bash
npx screenci record
npx screenci record -c screenci.config.ts
```
## What It Does
- Runs ScreenCI video tests
- Starts the recording pipeline
- Saves output under `.screenci/<video-name>/`
- Produces at least `recording.mp4` and `data.json`
## Connecting to an Account (optional)
`record` needs no account or setup step: without a `SCREENCI_SECRET`, it uploads under a local, anonymous trial session and prints a link to view the result.
To upload straight to an existing organization instead, get `SCREENCI_SECRET` into `screenci/.env` before the final recording (it does not block authoring or testing):
- Pass it to `init` as an argument: `npm init screenci@latest <SCREENCI_SECRET> -- --yes`.
- Or ask the user to copy `SCREENCI_SECRET` from their secrets page into `screenci/.env`. The org secret is shared across projects.
## Runtime Behavior
- Recording runs with local Playwright.
- `record` uploads every successful recording, with or without `SCREENCI_SECRET` set.
- Without an account, or on the free tier, renders include a ScreenCI watermark.
- Playwright arguments can be passed through after the command.
- After a successful `record`, report the URL it printed (starts with the app's domain, e.g. `https://app.screenci.com/record/...`) back to the user so they can open it. Without a `SCREENCI_SECRET`, this is also how they view and claim the anonymous trial recording.
## Recommended Workflow
```bash
# first verify the flow
npx screenci test
# then record
npx screenci record
```
## Workflow
Always run `npx screenci test` until it passes before running `npx screenci record`. Fix failures and rerun until green.
```bash
npx screenci test # verify selectors, flow, and narration
npx screenci record # capture the final recording
```
+2 -2
View File
@@ -4,7 +4,7 @@ import { randomUUID } from 'crypto'
import { resolve } from 'path'
import { getDevBackendUrl } from './linkSession.js'
// Anonymous CLI trial: `screenci record` with no SCREENCI_SECRET uploads
// Anonymous CLI trial: `screenci export` with no SCREENCI_SECRET uploads
// under a locally generated token instead of requiring an account up front.
// The token lives only in `.screenci/anon-session.json` and, server-side, in
// the anonymousSessions table (never printed or embedded in any URL).
@@ -137,7 +137,7 @@ export async function readAnonSessionRecordUrl(
return existing?.recordUrl ?? null
}
// How many separate `screenci record` runs an anonymous trial may upload before
// How many separate `screenci export` runs an anonymous trial may upload before
// it must sign up. Kept in sync with ANON_MAX_RECORDINGS server-side.
export const ANON_MAX_RECORDINGS = 3
+3 -3
View File
@@ -362,7 +362,7 @@ describe('applyCodegenRequest: orphaned (stale key) soft skip', () => {
{
requestId: 'req1',
videoName: 'Demo',
editId: 'param|delay|||0',
editId: 'param|delay',
editJson,
requiresRecord: true,
},
@@ -383,8 +383,8 @@ describe('applyCodegenRequest: orphaned (stale key) soft skip', () => {
// no call site to touch.
const staleDelayEdit = JSON.stringify({
type: 'paramEdit',
id: 'param|delay|||0',
target: { key: 'delay|||0' },
id: 'param|delay',
target: { key: 'delay' },
fields: { durationMs: 500 },
})
+1 -1
View File
@@ -2188,7 +2188,7 @@ describe('planCodeSync: typed refusal reasons', () => {
const result = plan(
inputWith({
editableOverrides: {
Demo: [{ key: 'delay|||0', values: { durationMs: 500 } }],
Demo: [{ key: 'delay', values: { durationMs: 500 } }],
},
})
)
+3 -2
View File
@@ -1635,8 +1635,9 @@ export function planCodeSync(
// A `slug#N` loop repeat execution has no call site of its own: locked,
// regardless of whether the snapshot carries the repeat. Checked before
// the orphaned test below so a repeat key is never mistaken for a stale
// one.
if (override.key.includes('#')) {
// one. Unstamped repeat keys (`delay #2`) contain a space before the
// ordinal; only bare `slug#N` editId keys are loop repeats.
if (override.key.includes('#') && !override.key.includes(' ')) {
for (const [field, value] of Object.entries(override.values)) {
if (value === undefined) continue
if (entry !== undefined && jsonEqual(entry.defaults[field], value)) {
+1 -1
View File
@@ -331,7 +331,7 @@ async function handleCodegenRequest(
deps.logger.info(
`Applied ${describeEditId(request.editId)} to "${request.videoName}"${
request.queuedBy !== undefined ? ` (queued by ${request.queuedBy})` : ''
}.`
}.${request.requiresRecord ? '' : ' Applies at render time, no re-record needed.'}`
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
+1 -1
View File
@@ -10,7 +10,7 @@ import type { RecordingData } from './recordingData.js'
function entry(editId?: string): EditableSnapshotEntry {
return {
key: editId ?? 'delay|||0',
key: editId ?? 'delay',
...(editId !== undefined && { editId }),
locked: false,
defaults: { durationMs: 100 },
+8 -8
View File
@@ -57,19 +57,19 @@ describe('planEditIdStamps', () => {
const result = stamp(
snapshotWith([
{
key: 'autoZoom|||0',
key: 'autoZoom',
locked: false,
defaults: {},
source: { file: FILE, line: 4 },
},
{
key: 'input|click|getByRole(button, name=Save)|0',
key: 'input click getByRole(button, name=Save)',
locked: false,
defaults: {},
source: { file: FILE, line: 5 },
},
{
key: 'input|pressSequentially|locator(#name)|0',
key: 'input pressSequentially locator(#name)',
locked: false,
defaults: {},
source: { file: FILE, line: 7 },
@@ -150,7 +150,7 @@ describe('planEditIdStamps', () => {
source: { file: FILE, line: 7 },
},
{
key: 'input|click|getByRole(button, name=Save)|0',
key: 'input click getByRole(button, name=Save)',
locked: false,
defaults: {},
},
@@ -172,13 +172,13 @@ describe('planEditIdStamps', () => {
const result = stamp(
snapshotWith([
{
key: 'input|click|locator(#x)|0',
key: 'input click locator(#x)',
locked: false,
defaults: {},
source: { file: FILE, line: 3 },
},
{
key: 'input|click|locator(#x)|1',
key: 'input click locator(#x) #1',
locked: false,
defaults: {},
source: { file: FILE, line: 3 },
@@ -197,7 +197,7 @@ describe('planEditIdStamps', () => {
videos: {
A: [
{
key: 'input|click|getByRole(button, name=Save)|0',
key: 'input click getByRole(button, name=Save)',
locked: false,
defaults: {},
source: { file: FILE, line: 5 },
@@ -205,7 +205,7 @@ describe('planEditIdStamps', () => {
],
B: [
{
key: 'input|click|getByRole(button, name=Save)|0',
key: 'input click getByRole(button, name=Save)',
locked: false,
defaults: {},
source: { file: FILE, line: 5 },
+7 -2
View File
@@ -79,9 +79,14 @@ export function allocateEditId(
return `${prefix}${next}`
}
/** `kind|subKind|...` of a legacy stable editable key. */
/**
* `kind` and `subKind` of an unstamped stable editable key (space-joined
* identity, e.g. `input click Save`). Only the leading segments matter here:
* every stampable kind either has no name (autoZoom, hide, speed, time) or
* always carries a subKind before the name (input).
*/
function parseKindSubKind(key: string): { kind: string; subKind: string } {
const parts = key.split('|')
const parts = key.split(' ')
return { kind: parts[0] ?? '', subKind: parts[1] ?? '' }
}
+7 -6
View File
@@ -85,21 +85,21 @@ describe('editable keys', () => {
name: 'save',
matcher: 'getByRole(button)',
})
).toBe('input|click|save')
).toBe('input click save')
})
it('falls back to the matcher, then to an empty identity part', () => {
it('falls back to the matcher, then drops absent identity parts', () => {
expect(
editableIdentityKey({
kind: 'input',
subKind: 'click',
matcher: 'getByRole(button)',
})
).toBe('input|click|getByRole(button)')
expect(editableIdentityKey({ kind: 'delay' })).toBe('delay||')
).toBe('input click getByRole(button)')
expect(editableIdentityKey({ kind: 'delay' })).toBe('delay')
})
it('appends the ordinal for the stable key', () => {
it('appends the ordinal for the stable key only when repeated', () => {
expect(
stableEditableKey({
kind: 'input',
@@ -107,7 +107,8 @@ describe('editable keys', () => {
matcher: 'getByRole(button)',
ordinal: 3,
})
).toBe('input|click|getByRole(button)|3')
).toBe('input click getByRole(button) #3')
expect(stableEditableKey({ kind: 'delay', ordinal: 0 })).toBe('delay')
})
})
+12 -6
View File
@@ -127,16 +127,20 @@ export function editableIdentityKey(
if (descriptor.editId !== undefined) return descriptor.editId
return [
descriptor.kind,
descriptor.subKind ?? '',
descriptor.name ?? descriptor.matcher ?? '',
].join('|')
descriptor.subKind,
descriptor.name ?? descriptor.matcher,
]
.filter((part): part is string => part !== undefined && part.length > 0)
.join(' ')
}
/**
* The stable key overrides are matched by across re-records. With an editId
* the key is the slug itself (`fill1`), or `fill1#N` for repeat executions of
* the same call site; without one it is the legacy matcher identity plus
* ordinal. `seq` stays out (it shifts whenever any action is added anywhere).
* the same call site; without one it is the human-readable matcher identity
* (`delay`, `input click Save`), plus ` #N` for ordinal > 0. `seq` stays out
* (it shifts whenever any action is added anywhere). Keys are shown verbatim
* in the UI and CLI, so they must stay readable: no pipes, no empty segments.
*/
export function stableEditableKey(
descriptor: Pick<
@@ -149,7 +153,9 @@ export function stableEditableKey(
? descriptor.editId
: `${descriptor.editId}#${descriptor.ordinal}`
}
return `${editableIdentityKey(descriptor)}|${descriptor.ordinal}`
return descriptor.ordinal === 0
? editableIdentityKey(descriptor)
: `${editableIdentityKey(descriptor)} #${descriptor.ordinal}`
}
/** Position allocated for a descriptor within the current recording. */
+3 -3
View File
@@ -16,7 +16,7 @@ describe('splitTimelineEditsByVideo', () => {
{
type: 'paramEdit',
id: 'p1',
target: { key: 'delay|||0' },
target: { key: 'delay' },
fields: { durationMs: 100 },
},
{
@@ -45,7 +45,7 @@ describe('splitTimelineEditsByVideo', () => {
broken: 'not a doc',
})
expect(overrides).toEqual({
demo: [{ key: 'delay|||0', values: { durationMs: 100 } }],
demo: [{ key: 'delay', values: { durationMs: 100 } }],
})
expect(codify.demo?.map((event) => event.id)).toEqual(['e1'])
expect(codify.broken).toBeUndefined()
@@ -159,7 +159,7 @@ describe('collectEditableFromRecordings', () => {
const collected = collectEditableFromRecordings(dir)
expect(collected['My video']).toEqual([
{
key: 'input|click|getByRole(button)|0',
key: 'input click getByRole(button)',
schemaKind: 'cursorMove',
locked: true,
lockedFields: ['moveDuration'],
+19 -10
View File
@@ -10,6 +10,7 @@ import type {
Easing,
NarrationFullScreenFit,
NarrationPosition,
HiddenShortcutRenderOptions,
RecordOptions,
RenderOptions,
ResolvedRenderOptions,
@@ -2616,16 +2617,24 @@ export class EventRecorder implements IEventRecorder {
motionBlur:
ro?.zoom?.motionBlur ?? RENDER_OPTIONS_DEFAULTS.zoom.motionBlur,
},
shortcuts: {
show: ro?.shortcuts?.show ?? RENDER_OPTIONS_DEFAULTS.shortcuts.show,
showSingle:
ro?.shortcuts?.showSingle ??
RENDER_OPTIONS_DEFAULTS.shortcuts.showSingle,
theme: ro?.shortcuts?.theme ?? RENDER_OPTIONS_DEFAULTS.shortcuts.theme,
...(ro?.shortcuts?.overrides !== undefined && {
overrides: ro.shortcuts.overrides,
}),
},
// Hidden for release: `shortcuts` is no longer on the public
// RenderOptions surface, but previously saved options (and the web
// editor's serialized settings) still carry it, so resolution reads it
// through the internal HiddenShortcutRenderOptions shape.
shortcuts: (() => {
const shortcuts = (ro as HiddenShortcutRenderOptions | undefined)
?.shortcuts
return {
show: shortcuts?.show ?? RENDER_OPTIONS_DEFAULTS.shortcuts.show,
showSingle:
shortcuts?.showSingle ??
RENDER_OPTIONS_DEFAULTS.shortcuts.showSingle,
theme: shortcuts?.theme ?? RENDER_OPTIONS_DEFAULTS.shortcuts.theme,
...(shortcuts?.overrides !== undefined && {
overrides: shortcuts.overrides,
}),
}
})(),
output: {
aspectRatio:
ro?.output?.aspectRatio ?? RENDER_OPTIONS_DEFAULTS.output.aspectRatio,
BIN
View File
Binary file not shown.
+9 -8
View File
@@ -577,7 +577,8 @@ function generateIslandPackageJson(projectName: string): string {
type: 'module',
scripts: {
test: 'screenci test',
record: 'screenci record',
edit: 'screenci edit',
export: 'screenci export',
format: 'prettier --write .',
},
},
@@ -1162,7 +1163,7 @@ async function installInitDependencies(
// How the new project is set up to authenticate: `ready` means a
// SCREENCI_SECRET is already configured (a pasted secret was verified, or the
// env already had one); `manual` means the user still needs to copy their
// secret from the secrets page, or just run `screenci record` with no secret
// secret from the secrets page, or just run `screenci export` with no secret
// to try it anonymously first (see anonSession.ts).
export type InitSecretOutcome = 'ready' | 'manual'
@@ -1209,11 +1210,11 @@ export async function setUpInitSecret(
}
if (!verification.ok) {
logger.warn(
'Could not verify the secret right now; writing it anyway. `screenci record` will confirm it.'
'Could not verify the secret right now; writing it anyway. `screenci export` will confirm it.'
)
}
// The generated config sets `envFile: '.env'`, so this matches what
// `screenci record` resolves later.
// `screenci export` resolves later.
await persistScreenCISecret(envPath, options.pastedSecret)
return 'ready'
}
@@ -1369,16 +1370,16 @@ ${appBuildHint} - name: Install dependencies
run: ${commands.playwrightRun} install --only-shell chromium
- id: record
name: Record
name: Export
working-directory: ${islandWorkflowPath}
env:
SCREENCI_SECRET: \${{ secrets.SCREENCI_SECRET }}
SCREENCI_GREP: \${{ inputs.grep }}
run: |
if [ -n "$SCREENCI_GREP" ]; then
${commands.screenciRun} record --grep "$SCREENCI_GREP"
${commands.screenciRun} export --grep "$SCREENCI_GREP"
else
${commands.screenciRun} record
${commands.screenciRun} export
fi
`
}
@@ -1691,7 +1692,7 @@ export async function runInit(
// The single positional is either a pasted SCREENCI_SECRET (a bare UUID,
// written straight to `.env`) or otherwise the project name
// (backward-friendly). With no secret at all, `screenci record` uploads
// (backward-friendly). With no secret at all, `screenci export` uploads
// anonymously instead (see anonSession.ts) — no setup token is needed.
let projectName = projectNameArg?.trim()
let pastedSecret: string | undefined
+1
View File
@@ -5,6 +5,7 @@ export type {
FPS,
Quality,
RecordOptions,
HiddenShortcutRenderOptions,
RenderOptions,
ResolvedRenderOptions,
ResolvedScreenshotRenderOptions,
+44 -34
View File
@@ -241,27 +241,12 @@ export type RenderOptions = {
*/
motionBlur?: number
}
/** Keyboard shortcut overlays recorded from `page.keyboard.press`. */
shortcuts?: {
/**
* Show modifier-combo shortcuts (e.g. `Shift+A`) as keycap overlays.
* Defaults to `true`.
*/
show?: boolean
/**
* Show single-key presses (e.g. `'A'`) as keycap overlays. Defaults to
* `false`.
*/
showSingle?: boolean
/** Keycap appearance. Defaults to `'dark'`. */
theme?: 'light' | 'dark'
/**
* Per-shortcut visibility overrides from the web editor timeline, keyed by
* the recorded event id. Wins over the per-call `show` option and the
* global toggles.
*/
overrides?: Record<string, { show: boolean }>
}
// Hidden for release: the keyboard shortcut overlay selection is removed
// from the public options surface (the web editor no longer offers it
// either). The runtime still resolves the options with their defaults, see
// HiddenShortcutRenderOptions below. Re-enable by moving that type's
// `shortcuts` field back here. Docs moved to docs/removed/keyboard-shortcuts.md
// at the repo root.
output?: {
/**
* Aspect ratio of the rendered video output.
@@ -297,6 +282,37 @@ export type RenderOptions = {
screenshot?: ScreenshotRenderOptions
}
/**
* Hidden for release: keyboard shortcut overlay options, removed from the
* public {@link RenderOptions} surface. Internal consumers (options
* resolution, the web editor's settings serialization) intersect this type so
* previously saved options keep resolving. Re-enable by moving the field back
* into {@link RenderOptions}.
*/
export type HiddenShortcutRenderOptions = {
/** Keyboard shortcut overlays recorded from `page.keyboard.press`. */
shortcuts?: {
/**
* Show modifier-combo shortcuts (e.g. `Shift+A`) as keycap overlays.
* Defaults to `true`.
*/
show?: boolean
/**
* Show single-key presses (e.g. `'A'`) as keycap overlays. Defaults to
* `false`.
*/
showSingle?: boolean
/** Keycap appearance. Defaults to `'dark'`. */
theme?: 'light' | 'dark'
/**
* Per-shortcut visibility overrides from the web editor timeline, keyed by
* the recorded event id. Wins over the per-call `show` option and the
* global toggles.
*/
overrides?: Record<string, { show: boolean }>
}
}
/**
* Default values applied to every field of {@link RenderOptions} that has a
* default. Used by {@link EventRecorder} when writing `data.json` so that the
@@ -1060,23 +1076,18 @@ type ScreenCIMouse = Omit<
/** Options for {@link ScreenCIKeyboard.press}. */
export type ScreenCIKeyboardPressOptions = NonNullable<
Parameters<Keyboard['press']>[1]
> & {
/**
* Visibility override for the keyboard shortcut overlay in the rendered
* video. `true` shows the keycaps even when the shortcut kind is disabled
* globally (e.g. a single key with `shortcuts.showSingle` off); `false`
* always hides them. Omit to follow `renderOptions.shortcuts`.
*/
show?: boolean
}
>
// Hidden for release: the per-press `show?: boolean` visibility override was
// removed from the public press options along with the shortcut render
// options. The runtime still records and strips it (see instrument.ts).
// Re-enable by restoring `& { show?: boolean }` here.
export type ScreenCIKeyboard = Omit<Keyboard, 'press'> & {
/**
* Presses a key or key combo (e.g. `'A'`, `'Shift+A'`, `'ControlOrMeta+K'`).
*
* The press is recorded as an animated keycap overlay shown at the bottom of
* the rendered video, subject to `renderOptions.shortcuts` and the `show`
* option.
* the rendered video.
*/
press(key: string, options?: ScreenCIKeyboardPressOptions): Promise<void>
}
@@ -1101,8 +1112,7 @@ export type ScreenCILocator = Omit<
* Presses a key or key combo (e.g. `'Enter'`, `'Shift+A'`) on the element.
*
* The press is recorded as an animated keycap overlay shown at the bottom of
* the rendered video, subject to `renderOptions.shortcuts` and the `show`
* option.
* the rendered video.
*/
press(
key: string,