mirror of
https://github.com/screenci/screenci.git
synced 2026-09-19 08:57:46 +08:00
editor: rename record command to export, edit specs, export run updates
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.`
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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))',
|
||||
},
|
||||
})
|
||||
```
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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] ?? '' }
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Binary file not shown.
+9
-8
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ export type {
|
||||
FPS,
|
||||
Quality,
|
||||
RecordOptions,
|
||||
HiddenShortcutRenderOptions,
|
||||
RenderOptions,
|
||||
ResolvedRenderOptions,
|
||||
ResolvedScreenshotRenderOptions,
|
||||
|
||||
+44
-34
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user