chore: update screenci

This commit is contained in:
Olli Paloviita
2026-07-06 00:54:35 +03:00
parent ab3793b3bc
commit bdcc61e223
31 changed files with 1215 additions and 153 deletions
+4
View File
@@ -19,6 +19,7 @@ const mockCreateReadStream = vi.fn()
const mockAppendFile = vi.fn()
const mockWriteFile = vi.fn()
const mockMkdir = vi.fn()
const mockRm = vi.fn()
const mockInput = vi.fn()
const mockConfirm = vi.fn()
const mockCreateHttpServer = vi.fn()
@@ -187,6 +188,7 @@ vi.mock('fs', () => ({
vi.mock('fs/promises', () => ({
appendFile: mockAppendFile,
rm: mockRm,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
@@ -194,6 +196,7 @@ vi.mock('fs/promises', () => ({
mkdir: mockMkdir,
default: {
appendFile: mockAppendFile,
rm: mockRm,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
@@ -236,6 +239,7 @@ describe('CLI', () => {
mockAppendFile.mockResolvedValue(undefined)
mockWriteFile.mockResolvedValue(undefined)
mockMkdir.mockResolvedValue(undefined)
mockRm.mockResolvedValue(undefined)
mockReaddir.mockResolvedValue([])
mockReadFileSync.mockImplementation(() => {
if (process.env.VITE_APP_BASE_URL === undefined) {
+4
View File
@@ -19,6 +19,7 @@ const mockCreateReadStream = vi.fn()
const mockAppendFile = vi.fn()
const mockWriteFile = vi.fn()
const mockMkdir = vi.fn()
const mockRm = vi.fn()
const mockInput = vi.fn()
const mockConfirm = vi.fn()
const mockCreateHttpServer = vi.fn()
@@ -187,6 +188,7 @@ vi.mock('fs', () => ({
vi.mock('fs/promises', () => ({
appendFile: mockAppendFile,
rm: mockRm,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
@@ -194,6 +196,7 @@ vi.mock('fs/promises', () => ({
mkdir: mockMkdir,
default: {
appendFile: mockAppendFile,
rm: mockRm,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
@@ -236,6 +239,7 @@ describe('CLI', () => {
mockAppendFile.mockResolvedValue(undefined)
mockWriteFile.mockResolvedValue(undefined)
mockMkdir.mockResolvedValue(undefined)
mockRm.mockResolvedValue(undefined)
mockReaddir.mockResolvedValue([])
mockReadFileSync.mockImplementation(() => {
if (process.env.VITE_APP_BASE_URL === undefined) {
+4
View File
@@ -22,6 +22,7 @@ const mockAppendFile = vi.fn()
const mockWriteFile = vi.fn()
const mockCopyFile = vi.fn()
const mockMkdir = vi.fn()
const mockRm = vi.fn()
const mockInput = vi.fn()
const mockConfirm = vi.fn()
const mockCreateHttpServer = vi.fn()
@@ -216,6 +217,7 @@ vi.mock('fs', () => ({
vi.mock('fs/promises', () => ({
appendFile: mockAppendFile,
copyFile: mockCopyFile,
rm: mockRm,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
@@ -224,6 +226,7 @@ vi.mock('fs/promises', () => ({
default: {
appendFile: mockAppendFile,
copyFile: mockCopyFile,
rm: mockRm,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
@@ -267,6 +270,7 @@ describe('CLI', () => {
mockWriteFile.mockResolvedValue(undefined)
mockCopyFile.mockResolvedValue(undefined)
mockMkdir.mockResolvedValue(undefined)
mockRm.mockResolvedValue(undefined)
mockReaddir.mockResolvedValue([])
mockReadFileSync.mockImplementation(() => {
if (process.env.VITE_APP_BASE_URL === undefined) {
+4
View File
@@ -19,6 +19,7 @@ const mockCreateReadStream = vi.fn()
const mockAppendFile = vi.fn()
const mockWriteFile = vi.fn()
const mockMkdir = vi.fn()
const mockRm = vi.fn()
const mockInput = vi.fn()
const mockConfirm = vi.fn()
const mockCreateHttpServer = vi.fn()
@@ -187,6 +188,7 @@ vi.mock('fs', () => ({
vi.mock('fs/promises', () => ({
appendFile: mockAppendFile,
rm: mockRm,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
@@ -194,6 +196,7 @@ vi.mock('fs/promises', () => ({
mkdir: mockMkdir,
default: {
appendFile: mockAppendFile,
rm: mockRm,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
@@ -236,6 +239,7 @@ describe('CLI', () => {
mockAppendFile.mockResolvedValue(undefined)
mockWriteFile.mockResolvedValue(undefined)
mockMkdir.mockResolvedValue(undefined)
mockRm.mockResolvedValue(undefined)
mockReaddir.mockResolvedValue([])
mockReadFileSync.mockImplementation(() => {
if (process.env.VITE_APP_BASE_URL === undefined) {
+398 -9
View File
@@ -26,6 +26,7 @@ const mockCreateReadStream = vi.fn()
const mockAppendFile = vi.fn()
const mockWriteFile = vi.fn()
const mockMkdir = vi.fn()
const mockRm = vi.fn()
const mockInput = vi.fn()
const mockConfirm = vi.fn()
const mockCreateHttpServer = vi.fn()
@@ -194,6 +195,7 @@ vi.mock('fs', () => ({
vi.mock('fs/promises', () => ({
appendFile: mockAppendFile,
rm: mockRm,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
@@ -201,6 +203,7 @@ vi.mock('fs/promises', () => ({
mkdir: mockMkdir,
default: {
appendFile: mockAppendFile,
rm: mockRm,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
@@ -243,6 +246,7 @@ describe('CLI', () => {
mockAppendFile.mockResolvedValue(undefined)
mockWriteFile.mockResolvedValue(undefined)
mockMkdir.mockResolvedValue(undefined)
mockRm.mockResolvedValue(undefined)
mockReaddir.mockResolvedValue([])
mockReadFileSync.mockImplementation(() => {
if (process.env.VITE_APP_BASE_URL === undefined) {
@@ -410,6 +414,130 @@ describe('CLI', () => {
})
})
describe('acquireRecordRunLock', () => {
it('refuses a fresh lock whose pid is still alive', async () => {
const addSignalListener = vi.fn()
const removeSignalListener = vi.fn()
const { acquireRecordRunLock } = await import('./cli')
await expect(
acquireRecordRunLock('/repo/.screenci', 'Test Project', {
pid: 123,
clock: () => new Date('2026-07-05T10:05:00.000Z'),
isPidAlive: (pid) => pid === 456,
fs: {
mkdir: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue(
JSON.stringify({
pid: 456,
startedAt: '2026-07-05T10:00:00.000Z',
projectName: 'Other Project',
})
),
writeFile: vi
.fn()
.mockRejectedValueOnce(
Object.assign(new Error('exists'), { code: 'EEXIST' })
),
rm: vi.fn().mockResolvedValue(undefined),
},
addSignalListener,
removeSignalListener,
removeLockSync: vi.fn(),
})
).rejects.toThrow(
`Another 'screenci record' is in progress (pid 456, started 2026-07-05T10:00:00.000Z, project "Other Project"). Wait for it or remove .screenci/.record.lock.`
)
expect(addSignalListener).not.toHaveBeenCalled()
expect(removeSignalListener).not.toHaveBeenCalled()
})
it('reclaims a stale lock when the pid is dead', async () => {
const mockWriteFile = vi
.fn()
.mockRejectedValueOnce(
Object.assign(new Error('exists'), { code: 'EEXIST' })
)
.mockResolvedValueOnce(undefined)
const mockRm = vi.fn().mockResolvedValue(undefined)
const { acquireRecordRunLock } = await import('./cli')
const lock = await acquireRecordRunLock(
'/repo/.screenci',
'Test Project',
{
pid: 123,
clock: () => new Date('2026-07-05T10:05:00.000Z'),
isPidAlive: () => false,
fs: {
mkdir: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue(
JSON.stringify({
pid: 456,
startedAt: '2026-07-05T10:00:00.000Z',
projectName: 'Other Project',
})
),
writeFile: mockWriteFile,
rm: mockRm,
},
addSignalListener: vi.fn(),
removeSignalListener: vi.fn(),
removeLockSync: vi.fn(),
}
)
expect(mockRm).toHaveBeenCalledWith('/repo/.screenci/.record.lock', {
force: true,
})
await lock.release()
})
it('reclaims a stale lock when it exceeds the max age', async () => {
const mockWriteFile = vi
.fn()
.mockRejectedValueOnce(
Object.assign(new Error('exists'), { code: 'EEXIST' })
)
.mockResolvedValueOnce(undefined)
const mockRm = vi.fn().mockResolvedValue(undefined)
const { acquireRecordRunLock } = await import('./cli')
const lock = await acquireRecordRunLock(
'/repo/.screenci',
'Test Project',
{
pid: 123,
clock: () => new Date('2026-07-05T20:05:00.000Z'),
isPidAlive: () => true,
fs: {
mkdir: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue(
JSON.stringify({
pid: 456,
startedAt: '2026-07-05T10:00:00.000Z',
projectName: 'Other Project',
})
),
writeFile: mockWriteFile,
rm: mockRm,
},
addSignalListener: vi.fn(),
removeSignalListener: vi.fn(),
removeLockSync: vi.fn(),
}
)
expect(mockRm).toHaveBeenCalledWith('/repo/.screenci/.record.lock', {
force: true,
})
await lock.release()
})
})
describe('record command', () => {
beforeEach(() => {
process.env.SCREENCI_SECRET = 'test-secret'
@@ -502,6 +630,99 @@ describe('CLI', () => {
)
})
it('fails the run without printing a results URL when only an unrelated sibling recording is uploadable', async () => {
process.argv = [
'node',
'cli.js',
'record',
'--config',
'test-fixtures/record-upload.config.ts',
'--grep',
'code cut',
]
mockReadFile.mockImplementation(async (path: string | URL) => {
const pathString = String(path)
if (pathString.endsWith('record-upload.config.ts')) {
return "export default { projectName: 'Test Project' }"
}
if (pathString.endsWith('/code-cut/data.json')) {
return JSON.stringify({
events: [],
metadata: { videoName: 'Code Cut' },
})
}
if (pathString.endsWith('/styled-backgrounds/data.json')) {
return JSON.stringify({
events: [],
metadata: { videoName: 'Styled Backgrounds' },
})
}
if (pathString.endsWith('package.json')) {
return JSON.stringify({ version: '0.0.32' })
}
return ''
})
mockReaddir.mockResolvedValue(['code-cut', 'styled-backgrounds'])
mockExistsSync.mockImplementation((path: string) => {
if (path.endsWith('test-fixtures/record-upload.config.ts')) return true
if (path.endsWith('/code-cut/data.json')) return true
if (path.endsWith('/styled-backgrounds/data.json')) return true
if (path.endsWith('/styled-backgrounds/recording.mp4')) return true
return false
})
mockSpawn.mockImplementation((_command: string, args: string[]) => {
const child = Object.assign(new EventEmitter(), {
unref: vi.fn(),
stdout: new EventEmitter(),
stderr: new EventEmitter(),
}) as unknown as ChildProcess & {
stdout: EventEmitter
stderr: EventEmitter
}
process.nextTick(() => {
if (args.includes('--list')) {
child.stdout.emit(
'data',
JSON.stringify({
suites: [{ specs: [{ title: 'Code Cut' }] }],
})
)
}
child.emit('close', 0)
})
return child
})
const { main } = await import('./cli')
await expect(main()).rejects.toThrow(
'Not all recordings succeeded to upload.'
)
expect(mockFetch).not.toHaveBeenCalledWith(
expect.stringContaining('/cli/upload/start'),
expect.objectContaining({
body: expect.stringContaining('Styled Backgrounds'),
})
)
expect(
mockWriteFile.mock.calls.some(
([path]) =>
typeof path === 'string' && path.endsWith('last-record.json')
)
).toBe(false)
expect(
loggerInfoSpy.mock.calls.some((call) =>
String(call[0]).includes('Results available at:')
)
).toBe(false)
expect(loggerWarnSpy).toHaveBeenCalledWith(
'Code Cut: Missing recording.mp4 for "Code Cut"'
)
})
it('injects Studio text overrides into the recording env', async () => {
process.argv = ['node', 'cli.js', 'record']
const overrides = { en: { heading: 'From Studio' } }
@@ -683,6 +904,7 @@ describe('CLI', () => {
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: false,
uploadedVideoNames: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -1474,6 +1696,7 @@ describe('CLI', () => {
projectId: null,
recordId: null,
hadFailures: false,
uploadedVideoNames: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -1513,6 +1736,7 @@ describe('CLI', () => {
projectId: null,
recordId: expect.any(String),
hadFailures: true,
uploadedVideoNames: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -1607,6 +1831,7 @@ describe('CLI', () => {
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: false,
uploadedVideoNames: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -1712,6 +1937,7 @@ describe('CLI', () => {
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: true,
uploadedVideoNames: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -1921,6 +2147,7 @@ describe('CLI', () => {
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: false,
uploadedVideoNames: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -2130,6 +2357,7 @@ describe('CLI', () => {
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: true,
uploadedVideoNames: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -2144,6 +2372,166 @@ describe('CLI', () => {
})
})
it('rejects the whole anonymous upload when the per-recording trial cap is exceeded', async () => {
mockReaddir.mockResolvedValue([
'demo-video',
'second-video',
'third-video',
'fourth-video',
])
mockReadFile.mockImplementation(async (path: string | URL) => {
const pathString = String(path)
if (pathString.endsWith('package.json')) {
return JSON.stringify({ version: '0.0.32' })
}
if (pathString.endsWith('data.json')) {
const videoName = pathString.includes('/second-video/')
? 'Second Demo'
: pathString.includes('/third-video/')
? 'Third Demo'
: pathString.includes('/fourth-video/')
? 'Fourth Demo'
: 'Demo'
return JSON.stringify({ events: [], metadata: { videoName } })
}
return ''
})
mockExistsSync.mockImplementation(
(path: string) =>
path.endsWith('data.json') || path.endsWith('recording.mp4')
)
const { uploadRecordings, anonCredential } = await import('./cli')
const result = await uploadRecordings(
'/repo/.screenci',
'Test Project',
'https://api.screenci.test',
anonCredential('anon-token')
)
expect(result).toEqual({
projectId: null,
recordId: null,
hadFailures: true,
uploadedVideoNames: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
failedVideoNames: ['Demo', 'Second Demo', 'Third Demo', 'Fourth Demo'],
failedVideoMessages: [
{
videoName: 'Demo',
message:
'Anonymous trials are capped at 3 videos/screenshots per recording. Split this into smaller runs or sign up to record more in one run.',
},
{
videoName: 'Second Demo',
message:
'Anonymous trials are capped at 3 videos/screenshots per recording. Split this into smaller runs or sign up to record more in one run.',
},
{
videoName: 'Third Demo',
message:
'Anonymous trials are capped at 3 videos/screenshots per recording. Split this into smaller runs or sign up to record more in one run.',
},
{
videoName: 'Fourth Demo',
message:
'Anonymous trials are capped at 3 videos/screenshots per recording. Split this into smaller runs or sign up to record more in one run.',
},
],
plan: null,
})
expect(mockFetch).not.toHaveBeenCalledWith(
expect.stringContaining('/cli/upload/start'),
expect.anything()
)
})
it('prints one shared warning when every anonymous failure has the same cap message', async () => {
process.argv = [
'node',
'cli.js',
'record',
'--config',
'test-fixtures/record-upload.config.ts',
]
delete process.env.SCREENCI_SECRET
mockReaddir.mockResolvedValue([
'ru-video',
'where-video',
'en-video',
'fi-video',
])
mockReadFile.mockImplementation(async (path: string | URL) => {
const pathString = String(path)
if (pathString.endsWith('package.json')) {
return JSON.stringify({ version: '0.0.32' })
}
if (pathString.endsWith('record-upload.config.ts')) {
return "export default { projectName: 'Test Project' }"
}
if (pathString.endsWith('data.json')) {
const metadata = pathString.includes('/ru-video/')
? { videoName: 'How to find docs', languages: ['ru'] }
: pathString.includes('/en-video/')
? { videoName: 'How to find docs', languages: ['en'] }
: pathString.includes('/fi-video/')
? { videoName: 'How to find docs', languages: ['fi'] }
: { videoName: 'Where to find docs' }
return JSON.stringify({ events: [], metadata })
}
return ''
})
mockExistsSync.mockImplementation(
(path: string) =>
path.endsWith('test-fixtures/record-upload.config.ts') ||
path.endsWith('data.json') ||
path.endsWith('recording.mp4')
)
mockFetch.mockImplementation(async (input: string | URL) => {
const url = String(input)
if (url.endsWith('/cli/anon-session-status')) {
return {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({
status: 'pending',
remaining: 3,
}),
text: vi.fn().mockResolvedValue(''),
}
}
return {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({}),
text: vi.fn().mockResolvedValue(''),
}
})
mockSpawn.mockImplementation(() => {
process.nextTick(() => mockChildProcess.emit('close', 0))
return mockChildProcess as unknown as ChildProcess
})
const { main } = await import('./cli')
await expect(main()).rejects.toThrow(
'Not all recordings succeeded to upload.'
)
const anonCapWarning =
'Anonymous trials are capped at 3 videos/screenshots per recording. Split this into smaller runs or sign up to record more in one run.'
expect(
loggerWarnSpy.mock.calls.filter((call) => call[0] === anonCapWarning)
).toHaveLength(1)
expect(loggerWarnSpy).toHaveBeenCalledWith(
"Not all recordings succeeded to upload. Failed videos: 'How to find docs [ru]', 'Where to find docs', 'How to find docs [en]', 'How to find docs [fi]'. Some videos may be missing from the project."
)
})
it('removes uploaded recording directories after successful upload', async () => {
mockReaddir.mockResolvedValue(['demo-video'])
mockReadFile.mockImplementation(async (path: string | URL) => {
@@ -2204,6 +2592,7 @@ describe('CLI', () => {
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: false,
uploadedVideoNames: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -2278,6 +2667,7 @@ describe('CLI', () => {
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: false,
uploadedVideoNames: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -2383,6 +2773,7 @@ describe('CLI', () => {
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: false,
uploadedVideoNames: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -2732,23 +3123,21 @@ describe('CLI', () => {
'Failed Demo: Upload limit reached for current plan.'
)
expect(loggerWarnSpy).toHaveBeenCalledWith(
'Not all recordings succeeded to upload. Failed videos: Failed Demo. Some videos may be missing from the project.'
"Not all recordings succeeded to upload. Failed videos: 'Failed Demo'. Some videos may be missing from the project."
)
// Failure warnings (stderr) must be emitted before the "Results available
// at:" line (stdout) so the URL stays directly under its message: in
// non-TTY CI logs stdout is buffered while stderr flushes immediately, so
// warnings logged afterwards would otherwise split the message from its URL.
const lastWarnOrder = Math.max(...loggerWarnSpy.mock.invocationCallOrder)
const resultsInfoCall = loggerInfoSpy.mock.calls.findIndex((call) =>
stripVTControlCharacters(String(call[0])).includes(
'Results available at:'
)
)
expect(resultsInfoCall).toBeGreaterThanOrEqual(0)
expect(resultsInfoCall).toBe(-1)
expect(
loggerInfoSpy.mock.invocationCallOrder[resultsInfoCall]
).toBeGreaterThan(lastWarnOrder)
mockWriteFile.mock.calls.some(
([path]) =>
typeof path === 'string' && path.endsWith('last-record.json')
)
).toBe(false)
})
it('formats expressive narration tier failures with a fix suggestion', async () => {
+4
View File
@@ -20,6 +20,7 @@ const mockCreateReadStream = vi.fn()
const mockAppendFile = vi.fn()
const mockWriteFile = vi.fn()
const mockMkdir = vi.fn()
const mockRm = vi.fn()
const mockInput = vi.fn()
const mockConfirm = vi.fn()
const mockCreateHttpServer = vi.fn()
@@ -188,6 +189,7 @@ vi.mock('fs', () => ({
vi.mock('fs/promises', () => ({
appendFile: mockAppendFile,
rm: mockRm,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
@@ -195,6 +197,7 @@ vi.mock('fs/promises', () => ({
mkdir: mockMkdir,
default: {
appendFile: mockAppendFile,
rm: mockRm,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
@@ -237,6 +240,7 @@ describe('CLI', () => {
mockAppendFile.mockResolvedValue(undefined)
mockWriteFile.mockResolvedValue(undefined)
mockMkdir.mockResolvedValue(undefined)
mockRm.mockResolvedValue(undefined)
mockReaddir.mockResolvedValue([])
mockReadFileSync.mockImplementation(() => {
if (process.env.VITE_APP_BASE_URL === undefined) {
+486 -81
View File
@@ -11,7 +11,15 @@ import {
} from 'fs'
import { createHash, randomUUID } from 'crypto'
import { createRequire } from 'module'
import { appendFile, readdir, readFile, stat, writeFile } from 'fs/promises'
import {
appendFile,
mkdir,
readdir,
readFile,
rm,
stat,
writeFile,
} from 'fs/promises'
import {
delimiter,
dirname,
@@ -62,7 +70,9 @@ import { OVERLAY_CACHE_DIR_NAME } from './src/htmlRasterizer.js'
import { maybeExtractVoiceSampleAudio } from './src/voiceSampleAudio.js'
import {
type CliCredential,
ANON_MAX_VIDEOS_PER_RECORDING,
ANON_SESSION_FILE,
ANON_TOKEN_HEADER,
anonCredential,
checkAnonSessionStatus,
deleteAnonSessionFile,
@@ -91,6 +101,8 @@ const SCREENCI_RECORD_DOCS_URL =
// Records the recordId of the most recent `screenci record` upload so
// `screenci info` can report exactly the run that was just made.
const SCREENCI_LAST_RECORD_FILE = 'last-record.json'
const SCREENCI_RECORD_LOCK_FILE = '.record.lock'
const SCREENCI_RECORD_LOCK_MAX_AGE_MS = 6 * 60 * 60 * 1000
const require = createRequire(import.meta.url)
type PlaywrightListReportSuite = {
@@ -107,6 +119,30 @@ type PlaywrightListReport = {
}>
}
type RecordRunLock = {
pid: number
startedAt: string
projectName: string
}
type RecordRunLockFs = {
mkdir: typeof mkdir
readFile: typeof readFile
writeFile: typeof writeFile
rm: typeof rm
}
type AcquireRecordRunLockDeps = {
fs: RecordRunLockFs
clock: () => Date
isPidAlive: (pid: number) => boolean
pid: number
addSignalListener: typeof process.on
removeSignalListener: typeof process.off
removeLockSync: (lockPath: string) => void
maxAgeMs: number
}
/**
* Reports whether the current session can complete an interactive browser
* sign-in. A session is interactive only when both stdin and stdout are
@@ -405,6 +441,7 @@ type PreparedUploadAsset = {
type UploadCandidate = {
entry: string
videoName: string
displayVideoName: string
data: RecordingData
preparedUploadAssets: PreparedUploadAsset[]
}
@@ -602,11 +639,42 @@ async function loadUploadCandidate(
return {
entry,
videoName,
displayVideoName: videoName,
data,
preparedUploadAssets,
}
}
function disambiguateUploadCandidateDisplayNames(
candidates: UploadCandidate[]
): UploadCandidate[] {
const counts = new Map<string, number>()
for (const candidate of candidates) {
counts.set(candidate.videoName, (counts.get(candidate.videoName) ?? 0) + 1)
}
return candidates.map((candidate) => {
if ((counts.get(candidate.videoName) ?? 0) <= 1) return candidate
const languages = candidate.data.metadata?.languages
if (Array.isArray(languages) && languages.length === 1) {
const [language] = languages
if (typeof language === 'string' && language.length > 0) {
return {
...candidate,
displayVideoName: `${candidate.videoName} [${language}]`,
}
}
}
if (candidate.entry !== candidate.videoName) {
return { ...candidate, displayVideoName: candidate.entry }
}
return candidate
})
}
async function uploadRecordingCandidate(
candidate: UploadCandidate,
screenciDir: string,
@@ -623,7 +691,13 @@ async function uploadRecordingCandidate(
recordId: string,
expectedScreenshotCount: number
): Promise<UploadJobResult> {
const { entry, videoName, data: rawData, preparedUploadAssets } = candidate
const {
entry,
videoName,
displayVideoName,
data: rawData,
preparedUploadAssets,
} = candidate
let projectId: string | null = null
let videoId: string | null = null
let plan: OrgPlan | null = null
@@ -645,8 +719,8 @@ async function uploadRecordingCandidate(
projectId: null,
videoId: null,
hadFailure: true,
videoName,
failureMessage: `Missing ${recordingFileName} for "${videoName}"`,
videoName: displayVideoName,
failureMessage: `Missing ${recordingFileName} for "${displayVideoName}"`,
recordId,
}
}
@@ -670,8 +744,11 @@ async function uploadRecordingCandidate(
projectId: null,
videoId: null,
hadFailure: true,
videoName,
failureMessage: formatUnresolvedAssetMessage(videoName, unresolved),
videoName: displayVideoName,
failureMessage: formatUnresolvedAssetMessage(
displayVideoName,
unresolved
),
recordId,
}
}
@@ -725,7 +802,7 @@ async function uploadRecordingCandidate(
videoId: null,
hadFailure: true,
elevenLabsKeyMissing: true,
videoName,
videoName: displayVideoName,
recordId,
}
}
@@ -733,9 +810,9 @@ async function uploadRecordingCandidate(
projectId: null,
videoId: null,
hadFailure: true,
videoName,
videoName: displayVideoName,
failureMessage: formatUploadStartFailureMessage(
videoName,
displayVideoName,
startResponse.status,
text,
credential.value
@@ -769,7 +846,7 @@ async function uploadRecordingCandidate(
if (startBody.dependencyErrors && startBody.dependencyErrors.length > 0) {
for (const depError of startBody.dependencyErrors) {
logger.error(
`Render dependency error in "${videoName}": ${depError.detail}. This render will fail until it is fixed.`
`Render dependency error in "${displayVideoName}": ${depError.detail}. This render will fail until it is fixed.`
)
}
}
@@ -834,8 +911,8 @@ async function uploadRecordingCandidate(
projectId,
videoId,
hadFailure: true,
videoName,
failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${extractBackendError(text)}${hint401(recordingResponse.status, credential.value)}`,
videoName: displayVideoName,
failureMessage: `Failed to upload recording for "${displayVideoName}": ${recordingResponse.status} ${extractBackendError(text)}${hint401(recordingResponse.status, credential.value)}`,
recordId,
...(plan !== null && { plan }),
}
@@ -847,7 +924,7 @@ async function uploadRecordingCandidate(
projectId,
videoId,
hadFailure: false,
videoName,
videoName: displayVideoName,
recordId,
...(studio !== undefined && { studio }),
...(plan !== null && { plan }),
@@ -870,7 +947,7 @@ async function uploadRecordingCandidate(
projectId,
videoId,
hadFailure: true,
videoName,
videoName: displayVideoName,
failureMessage: err instanceof Error ? err.message : String(err),
recordId,
...(plan !== null && { plan }),
@@ -882,8 +959,8 @@ async function uploadRecordingCandidate(
projectId,
videoId,
hadFailure: true,
videoName,
failureMessage: `Network error uploading "${videoName}": ${err instanceof Error ? err.message : String(err)}`,
videoName: displayVideoName,
failureMessage: `Network error uploading "${displayVideoName}": ${err instanceof Error ? err.message : String(err)}`,
recordId,
...(plan !== null && { plan }),
}
@@ -1830,6 +1907,37 @@ export function formatFailedVideoMessage(
return `${videoName}: ${message}`
}
function quoteFailedVideoName(videoName: string): string {
return `'${videoName.replaceAll("'", "\\'")}'`
}
export function formatFailedVideoNamesSummary(videoNames: string[]): string {
if (videoNames.length === 0) return 'unknown'
return videoNames
.map((videoName) => quoteFailedVideoName(videoName))
.join(', ')
}
export function collapseFailedVideoWarnings(
failures: Array<{ videoName: string; message: string }>
): string[] {
const byMessage = new Map<string, string[]>()
for (const failure of failures) {
const names = byMessage.get(failure.message)
if (names) names.push(failure.videoName)
else byMessage.set(failure.message, [failure.videoName])
}
return [...byMessage.entries()].map(([message, videoNames]) => {
if (videoNames.length === 1) {
return formatFailedVideoMessage(videoNames[0] ?? 'unknown', message)
}
return message
})
}
export function printUploadStartFailureMessage(
videoName: string,
status: number,
@@ -2106,11 +2214,13 @@ export async function uploadRecordings(
apiUrl: string,
credential: CliCredential,
specificEntry?: string,
verbose = false
verbose = false,
allowedVideoNames?: readonly string[]
): Promise<{
projectId: string | null
recordId: string | null
hadFailures: boolean
uploadedVideoNames: string[]
failedVideoNames: string[]
failedVideoMessages: Array<{ videoName: string; message: string }>
studioNotices: StudioUploadNotice[]
@@ -2129,6 +2239,7 @@ export async function uploadRecordings(
projectId: null,
recordId: null,
hadFailures: false,
uploadedVideoNames: [],
failedVideoNames: [],
failedVideoMessages: [],
studioNotices: [],
@@ -2145,22 +2256,69 @@ export async function uploadRecordings(
let firstProjectId: string | null = null
try {
const candidates = (
await Promise.all(
entries.map(async (entry) => {
uploadAbort.throwIfAborted()
return await loadUploadCandidate(screenciDir, entry, verbose)
})
)
).filter((candidate): candidate is UploadCandidate => candidate !== null)
const candidates = disambiguateUploadCandidateDisplayNames(
(
await Promise.all(
entries.map(async (entry) => {
uploadAbort.throwIfAborted()
return await loadUploadCandidate(screenciDir, entry, verbose)
})
)
).filter((candidate): candidate is UploadCandidate => candidate !== null)
)
const requestedVideoNames =
allowedVideoNames !== undefined ? new Set(allowedVideoNames) : null
const filteredCandidates =
requestedVideoNames === null
? candidates
: candidates.filter((candidate) =>
requestedVideoNames.has(candidate.videoName)
)
if (candidates.length === 0) {
if (filteredCandidates.length === 0) {
const missingRequestedVideoNames =
requestedVideoNames === null
? []
: [...requestedVideoNames].filter(
(videoName) =>
!candidates.some(
(candidate) => candidate.videoName === videoName
)
)
return {
projectId: null,
recordId: null,
hadFailures: false,
failedVideoNames: [],
failedVideoMessages: [],
hadFailures: missingRequestedVideoNames.length > 0,
uploadedVideoNames: [],
failedVideoNames: missingRequestedVideoNames,
failedVideoMessages: missingRequestedVideoNames.map((videoName) => ({
videoName,
message: `No recorded output found for "${videoName}"`,
})),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
plan: null,
}
}
if (
credential.header === ANON_TOKEN_HEADER &&
filteredCandidates.length > ANON_MAX_VIDEOS_PER_RECORDING
) {
const failureMessage = `Anonymous trials are capped at ${ANON_MAX_VIDEOS_PER_RECORDING} videos/screenshots per recording. Split this into smaller runs or sign up to record more in one run.`
return {
projectId: null,
recordId: null,
hadFailures: true,
uploadedVideoNames: [],
failedVideoNames: filteredCandidates.map(
(candidate) => candidate.displayVideoName
),
failedVideoMessages: filteredCandidates.map((candidate) => ({
videoName: candidate.displayVideoName,
message: failureMessage,
})),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -2169,19 +2327,19 @@ export async function uploadRecordings(
}
const progressReporter = createUploadProgressReporter(
candidates.map((candidate) => candidate.videoName),
filteredCandidates.map((candidate) => candidate.displayVideoName),
verbose
)
// Screenshots from this run render together on one machine; the backend
// waits for all of them to land before dispatching the batch, so it needs
// to know how many to expect.
const screenshotCount = candidates.filter(
const screenshotCount = filteredCandidates.filter(
(candidate) => candidate.data.output === 'screenshot'
).length
const results = await Promise.all(
candidates.map(
filteredCandidates.map(
async (candidate, index) =>
await uploadRecordingCandidate(
candidate,
@@ -2204,6 +2362,9 @@ export async function uploadRecordings(
const resolvedPlan =
results.find((result) => result.plan !== undefined)?.plan ?? null
const hadFailures = results.some((result) => result.hadFailure)
const uploadedVideoNames = results
.filter((result) => !result.hadFailure)
.map((result) => result.videoName)
const failedVideoNames = results
.filter((result) => result.hadFailure)
.map((result) => result.videoName)
@@ -2237,6 +2398,7 @@ export async function uploadRecordings(
projectId: firstProjectId,
recordId,
hadFailures,
uploadedVideoNames,
failedVideoNames,
failedVideoMessages,
studioNotices,
@@ -2269,6 +2431,46 @@ async function writeGitHubProjectOutput(projectUrl: string): Promise<void> {
await appendFile(githubOutput, `screenci_project_url=${projectUrl}\n`)
}
/**
* The builder titles each per-language Playwright test `${videoName} [${lang}]`
* (src/builder.ts) so every language pass has a unique test title, while the
* shared grouping key it writes to `metadata.videoName` carries NO language
* suffix. `screenci record` discovers the videos to expect by their test titles,
* but the uploader matches those against each recording's `metadata.videoName`.
* Strip the trailing ` [<lang>]` so the requested name matches the recorded one;
* otherwise a language-decorated title never matches and every upload reports
* "No recorded output found". Only a language-code-shaped bracket is stripped, so
* an unrelated trailing bracket in a video name is left intact.
*/
export function stripTestTitleLanguageSuffix(title: string): string {
// Language codes are lowercase (ISO 639: en, es, zh, ...), with an optional
// region subtag (pt-BR). Requiring lowercase avoids stripping unrelated
// capitalized brackets like ` [New]`.
return title.replace(/ \[[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})?\]$/, '')
}
async function collectRequestedRecordVideoNames(
configPath: string,
additionalArgs: string[],
languages: string | undefined
): Promise<string[]> {
const envForDiscovery = {
...process.env,
SCREENCI_CONFIG_DIR: dirname(configPath),
SCREENCI_RECORDING: 'true',
...(languages ? { [SCREENCI_LANGUAGES_ENV]: languages } : {}),
}
const titles = await collectDiscoveredTestTitles(
configPath,
additionalArgs,
envForDiscovery
)
// Requested names are matched against `metadata.videoName` (no language
// suffix), so recover the videoName from each per-language test title.
return [...new Set(titles.map(stripTestTitleLanguageSuffix))]
}
async function loadScreenCIConfigAndEnv(configPath?: string): Promise<{
resolvedConfigPath: string
screenciConfig: ScreenCIConfig
@@ -2738,6 +2940,171 @@ async function triggerRemoteRun(
)
}
function getRecordRunLockPath(screenciDir: string): string {
return resolve(screenciDir, SCREENCI_RECORD_LOCK_FILE)
}
function defaultIsPidAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) return false
try {
process.kill(pid, 0)
return true
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ESRCH') {
return false
}
return true
}
}
function parseRecordRunLock(raw: string): RecordRunLock | null {
try {
const parsed = JSON.parse(raw) as {
pid?: unknown
startedAt?: unknown
projectName?: unknown
}
return typeof parsed.pid === 'number' &&
Number.isInteger(parsed.pid) &&
parsed.pid > 0 &&
typeof parsed.startedAt === 'string' &&
parsed.startedAt.length > 0 &&
typeof parsed.projectName === 'string' &&
parsed.projectName.length > 0
? {
pid: parsed.pid,
startedAt: parsed.startedAt,
projectName: parsed.projectName,
}
: null
} catch {
return null
}
}
function isRecordRunLockStale(
lock: RecordRunLock,
now: Date,
isPidAlive: (pid: number) => boolean,
maxAgeMs: number
): boolean {
const startedAtMs = Date.parse(lock.startedAt)
if (Number.isNaN(startedAtMs)) return true
if (now.getTime() - startedAtMs > maxAgeMs) return true
return !isPidAlive(lock.pid)
}
function formatRecordRunLockError(lock: RecordRunLock): string {
return `Another 'screenci record' is in progress (pid ${lock.pid}, started ${lock.startedAt}, project "${lock.projectName}"). Wait for it or remove .screenci/.record.lock.`
}
export async function acquireRecordRunLock(
screenciDir: string,
projectName: string,
deps: Partial<AcquireRecordRunLockDeps> = {}
): Promise<{ release: () => Promise<void> }> {
const resolvedDeps: AcquireRecordRunLockDeps = {
fs: deps.fs ?? { mkdir, readFile, writeFile, rm },
clock: deps.clock ?? (() => new Date()),
isPidAlive: deps.isPidAlive ?? defaultIsPidAlive,
pid: deps.pid ?? process.pid,
addSignalListener: deps.addSignalListener ?? process.on.bind(process),
removeSignalListener:
deps.removeSignalListener ?? process.off.bind(process),
removeLockSync:
deps.removeLockSync ??
((lockPath) => {
rmSync(lockPath, { force: true })
}),
maxAgeMs: deps.maxAgeMs ?? SCREENCI_RECORD_LOCK_MAX_AGE_MS,
}
const lockPath = getRecordRunLockPath(screenciDir)
const lock: RecordRunLock = {
pid: resolvedDeps.pid,
startedAt: resolvedDeps.clock().toISOString(),
projectName,
}
await resolvedDeps.fs.mkdir(screenciDir, { recursive: true })
for (;;) {
try {
await resolvedDeps.fs.writeFile(
lockPath,
`${JSON.stringify(lock, null, 2)}\n`,
{ flag: 'wx' }
)
break
} catch (error) {
if (
!(error instanceof Error) ||
!('code' in error) ||
error.code !== 'EEXIST'
) {
throw error
}
let existingLock: RecordRunLock | null = null
try {
existingLock = parseRecordRunLock(
await resolvedDeps.fs.readFile(lockPath, 'utf-8')
)
} catch {
existingLock = null
}
if (
existingLock !== null &&
!isRecordRunLockStale(
existingLock,
resolvedDeps.clock(),
resolvedDeps.isPidAlive,
resolvedDeps.maxAgeMs
)
) {
throw new Error(formatRecordRunLockError(existingLock))
}
await resolvedDeps.fs.rm(lockPath, { force: true })
}
}
let released = false
const removeLockSync = () => {
if (released) return
released = true
resolvedDeps.removeSignalListener('SIGINT', handleSigint)
resolvedDeps.removeSignalListener('SIGTERM', handleSigterm)
try {
resolvedDeps.removeLockSync(lockPath)
} catch {
// best-effort during signal shutdown
}
}
const release = async () => {
if (released) return
released = true
resolvedDeps.removeSignalListener('SIGINT', handleSigint)
resolvedDeps.removeSignalListener('SIGTERM', handleSigterm)
try {
await resolvedDeps.fs.rm(lockPath, { force: true })
} catch {
// best-effort cleanup
}
}
const handleSigint = () => removeLockSync()
const handleSigterm = () => removeLockSync()
resolvedDeps.addSignalListener('SIGINT', handleSigint)
resolvedDeps.addSignalListener('SIGTERM', handleSigterm)
return { release }
}
function getLastRecordFilePath(screenciDir: string): string {
return resolve(screenciDir, SCREENCI_LAST_RECORD_FILE)
}
@@ -2907,7 +3274,8 @@ export async function ensureAnonRecordingAllowedOrExit(
async function uploadRecordedVideosForConfig(
configPath: string | undefined,
playwrightFailure: Error | null,
verbose: boolean
verbose: boolean,
requestedVideoNames?: readonly string[]
): Promise<void> {
// After recording, upload results to API if configured. `run` already
// resolved the config (or exited), so this best-effort lookup only acts
@@ -2959,6 +3327,7 @@ async function uploadRecordedVideosForConfig(
projectId: string | null
recordId: string | null
hadFailures: boolean
uploadedVideoNames: string[]
failedVideoNames: string[]
failedVideoMessages: Array<{ videoName: string; message: string }>
studioNotices: StudioUploadNotice[]
@@ -2969,6 +3338,7 @@ async function uploadRecordedVideosForConfig(
projectId: null,
recordId: null,
hadFailures: false,
uploadedVideoNames: [],
failedVideoNames: [],
failedVideoMessages: [],
studioNotices: [],
@@ -2983,7 +3353,8 @@ async function uploadRecordedVideosForConfig(
apiUrl,
credential,
undefined,
verbose
verbose,
requestedVideoNames
)
} catch (err) {
if (isUploadCancelledError(err)) {
@@ -2995,6 +3366,7 @@ async function uploadRecordedVideosForConfig(
projectId,
recordId,
hadFailures,
uploadedVideoNames,
failedVideoNames,
failedVideoMessages,
studioNotices,
@@ -3002,8 +3374,14 @@ async function uploadRecordedVideosForConfig(
notices,
plan,
} = uploadResult
const requestedUploadSucceeded =
!hadFailures &&
(requestedVideoNames === undefined ||
requestedVideoNames.every((videoName) =>
uploadedVideoNames.includes(videoName)
))
// Remember this run so `screenci info` can report exactly it.
if (recordId !== null) {
if (recordId !== null && requestedUploadSucceeded) {
await saveLastRecordId(screenciDir, recordId)
}
// Emit upload-failure warnings (stderr) before the results block.
@@ -3013,17 +3391,17 @@ async function uploadRecordedVideosForConfig(
// it from its URL. Reporting failures first keeps the URL directly
// under its message.
if (hadFailures) {
for (const failedVideo of failedVideoMessages) {
logger.warn(
formatFailedVideoMessage(failedVideo.videoName, failedVideo.message)
)
for (const warning of collapseFailedVideoWarnings(
failedVideoMessages
)) {
logger.warn(warning)
}
logger.warn(
`Not all recordings succeeded to upload. Failed videos: ${failedVideoNames.join(', ') || 'unknown'}. Some videos may be missing from the project.`
`Not all recordings succeeded to upload. Failed videos: ${formatFailedVideoNamesSummary(failedVideoNames)}. Some videos may be missing from the project.`
)
}
let resultUrl: string | null = null
if (recordId !== null && projectId !== null) {
if (requestedUploadSucceeded && recordId !== null && projectId !== null) {
const recordUrl = `${appUrl}/record/${recordId}`
resultUrl = recordUrl
await writeGitHubProjectOutput(recordUrl)
@@ -3034,7 +3412,7 @@ async function uploadRecordedVideosForConfig(
: 'Recording finished, rendering in progress. Results available at:'
)
logger.info(pc.cyan(recordUrl))
} else if (projectId !== null) {
} else if (requestedUploadSucceeded && projectId !== null) {
const projectUrl = `${appUrl}/project/${projectId}`
resultUrl = projectUrl
await writeGitHubProjectOutput(projectUrl)
@@ -3165,48 +3543,75 @@ export async function main() {
return
}
let playwrightFailure: Error | null = null
validateArgs(parsed.otherArgs)
// 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.'
)
}
if (process.env.SCREENCI_RECORDING === 'true') return
await uploadRecordedVideosForConfig(
parsed.configPath,
playwrightFailure,
parsed.verbose
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
)
if (playwrightFailure !== null) {
throw playwrightFailure
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.'
)
}
if (process.env.SCREENCI_RECORDING === 'true') return
await uploadRecordedVideosForConfig(
parsed.configPath,
playwrightFailure,
parsed.verbose,
requestedVideoNames
)
if (playwrightFailure !== null) {
throw playwrightFailure
}
} finally {
await recordRunLock.release()
}
})
+32
View File
@@ -27,6 +27,7 @@ const mockCreateReadStream = vi.fn()
const mockAppendFile = vi.fn()
const mockWriteFile = vi.fn()
const mockMkdir = vi.fn()
const mockRm = vi.fn()
const mockInput = vi.fn()
const mockConfirm = vi.fn()
const mockCreateHttpServer = vi.fn()
@@ -195,6 +196,7 @@ vi.mock('fs', () => ({
vi.mock('fs/promises', () => ({
appendFile: mockAppendFile,
rm: mockRm,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
@@ -202,6 +204,7 @@ vi.mock('fs/promises', () => ({
mkdir: mockMkdir,
default: {
appendFile: mockAppendFile,
rm: mockRm,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
@@ -244,6 +247,7 @@ describe('CLI', () => {
mockAppendFile.mockResolvedValue(undefined)
mockWriteFile.mockResolvedValue(undefined)
mockMkdir.mockResolvedValue(undefined)
mockRm.mockResolvedValue(undefined)
mockReaddir.mockResolvedValue([])
mockReadFileSync.mockImplementation(() => {
if (process.env.VITE_APP_BASE_URL === undefined) {
@@ -968,4 +972,32 @@ describe('CLI', () => {
expect(fn).toHaveBeenCalledTimes(1)
})
})
describe('stripTestTitleLanguageSuffix', () => {
it('strips a trailing language suffix so titles match metadata.videoName', async () => {
const { stripTestTitleLanguageSuffix } = await import('./cli')
// Builder titles per-language tests `${videoName} [${lang}]`; the uploaded
// recording is keyed by the unsuffixed videoName.
expect(
stripTestTitleLanguageSuffix('ScreenCI product pitch (code cut) [en]')
).toBe('ScreenCI product pitch (code cut)')
expect(stripTestTitleLanguageSuffix('Tour [es]')).toBe('Tour')
expect(stripTestTitleLanguageSuffix('Tour [pt-BR]')).toBe('Tour')
})
it('leaves an unsuffixed title and non-language brackets intact', async () => {
const { stripTestTitleLanguageSuffix } = await import('./cli')
expect(stripTestTitleLanguageSuffix('Tour')).toBe('Tour')
// Only a trailing language-code-shaped bracket is stripped.
expect(stripTestTitleLanguageSuffix('Dashboard [New]')).toBe(
'Dashboard [New]'
)
expect(stripTestTitleLanguageSuffix('Report [v2]')).toBe('Report [v2]')
// A video name that itself ends in `[en]` keeps that, dropping only the
// builder-appended language suffix.
expect(stripTestTitleLanguageSuffix('Dashboard [en] [en]')).toBe(
'Dashboard [en]'
)
})
})
})
+4
View File
@@ -19,6 +19,7 @@ const mockCreateReadStream = vi.fn()
const mockAppendFile = vi.fn()
const mockWriteFile = vi.fn()
const mockMkdir = vi.fn()
const mockRm = vi.fn()
const mockInput = vi.fn()
const mockConfirm = vi.fn()
const mockCreateHttpServer = vi.fn()
@@ -187,6 +188,7 @@ vi.mock('fs', () => ({
vi.mock('fs/promises', () => ({
appendFile: mockAppendFile,
rm: mockRm,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
@@ -194,6 +196,7 @@ vi.mock('fs/promises', () => ({
mkdir: mockMkdir,
default: {
appendFile: mockAppendFile,
rm: mockRm,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
@@ -236,6 +239,7 @@ describe('CLI', () => {
mockAppendFile.mockResolvedValue(undefined)
mockWriteFile.mockResolvedValue(undefined)
mockMkdir.mockResolvedValue(undefined)
mockRm.mockResolvedValue(undefined)
mockReaddir.mockResolvedValue([])
mockReadFileSync.mockImplementation(() => {
if (process.env.VITE_APP_BASE_URL === undefined) {
+5 -4
View File
@@ -280,10 +280,11 @@ Behavior:
- enables recording timing
- 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 every successful recording, with or without `SCREENCI_SECRET`
- prints a project URL after upload when rendering has been started remotely
- 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
- 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 renders include a ScreenCI watermark; paid plans remove it and raise render and active-video limits, and unlock multiple languages plus expressive tone control
@@ -353,8 +354,8 @@ Lists **every video in the project** with its public URLs as JSON, keyed by
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 run, `info` also reports that run. Each
`screenci record` upload stores its unique record id in
When this machine has recorded a successful run, `info` also reports that run.
Each `screenci record` 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
+5
View File
@@ -322,6 +322,11 @@ table, so any language below works with them regardless of `modelType`.
(`values` and other non-narration features also work for every key regardless of
model, since they carry no synthesized speech.)
One built-in-voice exception is worth calling out: Russian (`ru`) currently
supports the built-in names `Ava`, `Daniel`, `Emma`, `Leo`, `Lily`, `Max`,
`Miles`, and `Nora`. When a shared default also needs to cover Russian, use one
of those names; the built-in fallback voice is `Ava`.
### Available with any model
These narrate with both the consistent (default) and expressive models:
+11 -2
View File
@@ -22,6 +22,11 @@ The default voice is set once with `renderOptions.narration.voice`, in
value) is the most specific override. Changing the voice re-renders without
re-recording; changing the spoken text re-records.
Built-in voices are shared across supported narration languages with one current
exception: for Russian (`ru`), the built-in choices are `Ava`, `Daniel`, `Emma`,
`Leo`, `Lily`, `Max`, `Miles`, and `Nora`. The built-in fallback voice is `Ava`,
so an unconfigured video stays valid there too.
`style` prompts require the Business tier, as does choosing `modelType:
'expressive'` for a language that also has a consistent voice (a tone upgrade).
A language whose only built-in voice is the expressive model uses it
@@ -86,7 +91,7 @@ config or `video.use(...)` default.
import { video, voices } from 'screenci'
// The default voice (how the narration is spoken).
video.use({ renderOptions: { narration: { voice: { name: voices.Sophie } } } })
video.use({ renderOptions: { narration: { voice: { name: voices.Ava } } } })
video.narration({
en: {
@@ -146,7 +151,7 @@ import { video, voices } from 'screenci'
// language. Both are render options set with `use`.
video.use({
renderOptions: {
narration: { corner: 'top-right', voice: { name: voices.Sophie } },
narration: { corner: 'top-right', voice: { name: voices.Ava } },
},
})
@@ -322,6 +327,10 @@ to regenerate long blocks when only one line changes.
ScreenCI ships with built-in voices that you can use across supported languages
through the `voices` export.
Russian (`ru`) currently has a smaller built-in subset: `Ava`, `Daniel`, `Emma`,
`Leo`, `Lily`, `Max`, `Miles`, and `Nora`. Use one of those names whenever a cue
is spoken in Russian.
| Name | Gender | Character |
| ---------- | ------ | ------------------------------ |
| `Adrian` | Male | Clear, direct, and structured |
+1 -1
View File
@@ -288,7 +288,7 @@ import { video, voices } from 'screenci'
// Voice is a render option (how narration is spoken).
video.use({
renderOptions: { narration: { voice: { name: voices.Sophie } } },
renderOptions: { narration: { voice: { name: voices.Ava } } },
})
// Localized narration cues by language.
@@ -3,7 +3,7 @@ import { autoZoom, hide, resetZoom, video, voices, zoomTo } from 'screenci'
video.use({
renderOptions: {
narration: {
voice: { name: voices.Sophie, style: 'Friendly product guide' },
voice: { name: voices.Ava, style: 'Friendly product guide' },
},
output: {
background: {
+1 -1
View File
@@ -3,7 +3,7 @@ import { hide, resetZoom, video, voices, zoomTo } from 'screenci'
video.use({
renderOptions: {
narration: {
voice: { name: voices.Sophie, style: 'Friendly product guide' },
voice: { name: voices.Ava, style: 'Friendly product guide' },
},
},
})
+1 -1
View File
@@ -3,7 +3,7 @@ import { hide, resetZoom, video, voices, zoomTo } from 'screenci'
video.use({
renderOptions: {
narration: {
voice: { name: voices.Sophie, style: 'Friendly product guide' },
voice: { name: voices.Ava, style: 'Friendly product guide' },
},
},
})
+1 -1
View File
@@ -3,7 +3,7 @@ import { hide, resetZoom, video, voices, zoomTo } from 'screenci'
video.use({
renderOptions: {
narration: {
voice: { name: voices.Sophie, style: 'Friendly product guide' },
voice: { name: voices.Ava, style: 'Friendly product guide' },
},
},
})
@@ -3,7 +3,7 @@ import { hide, resetZoom, video, voices, zoomTo } from 'screenci'
video.use({
renderOptions: {
narration: {
voice: { name: voices.Sophie, style: 'Friendly product guide' },
voice: { name: voices.Ava, style: 'Friendly product guide' },
},
},
})
+1
View File
@@ -18,6 +18,7 @@ export const ANON_SESSION_FILE = 'anon-session.json'
export const ANON_TOKEN_HEADER = 'X-ScreenCI-Anon-Token'
export const SECRET_HEADER = 'X-ScreenCI-Secret'
export const ANON_MAX_VIDEOS_PER_RECORDING = 3
// Canonical Terms of Service URL. Hardcoded like the docs links elsewhere in
// the CLI (the legal pages are not environment-specific). Anonymous trial
+5 -5
View File
@@ -713,7 +713,7 @@ describe('createNarration', () => {
)
})
it('defaults the top-level voice to Sophie when omitted', async () => {
it('defaults the top-level voice to Ava when omitted', async () => {
const cues = createNarration({
en: {
intro: 'Hello world',
@@ -729,15 +729,15 @@ describe('createNarration', () => {
'intro',
undefined,
{
en: { text: 'Hello world', voice: voices.Sophie },
es: { text: 'Hola mundo', voice: voices.Sophie },
en: { text: 'Hello world', voice: voices.Ava },
es: { text: 'Hola mundo', voice: voices.Ava },
}
)
expect(recorder.registerVoiceForLang).toHaveBeenCalledWith('en', {
name: 'Sophie',
name: 'Ava',
})
expect(recorder.registerVoiceForLang).toHaveBeenCalledWith('es', {
name: 'Sophie',
name: 'Ava',
})
})
+28
View File
@@ -36,6 +36,34 @@ describe('createNarration type constraints', () => {
})
})
it('accepts a shared built-in default that also works for Russian', () => {
createNarration({
voice: { name: voices.Ava },
en: { intro: 'Hello' },
ru: { intro: 'Privet' },
})
})
it('rejects a shared built-in default that is unavailable for Russian', () => {
createNarration({
// @ts-expect-error — this built-in voice is not available for Russian
voice: { name: voices.Sophie },
en: { intro: 'Hello' },
ru: { intro: 'Privet' },
})
})
it('rejects an unavailable Russian built-in per-language override', () => {
createNarration({
en: { intro: 'Hello' },
ru: {
// @ts-expect-error — this built-in voice is not available for Russian
voice: { name: voices.Sophie },
intro: 'Privet',
},
})
})
it('accepts mixed value types for the same key', () => {
createNarration({
voice: { name: voices.Ava },
+19 -14
View File
@@ -12,13 +12,15 @@ import type {
import { parseTimelineOffset, type TimelineOffset } from './timelineOffset.js'
import { validateCrop, resolveSourceTrim } from './sourceTrim.js'
import {
defaultBuiltInVoice,
supportedLanguages,
voices,
type VoiceKey,
type Lang,
type CustomVoiceRef,
} from './voices.js'
import type {
AnyLangNarrationOverride,
AnyTopLevelVoiceConfig,
TopLevelVoiceConfig,
LangNarrationOverride,
} from './voiceConfig.js'
@@ -292,13 +294,14 @@ type UnionToIntersection<U> = (
* Uses each language's key set with CueMapValue values before intersecting,
* so value types don't conflict (e.g. string vs { path, subtitle } for the same key).
*/
type LanguageEntryBase = {
voice?: LangNarrationOverride
type LanguageEntryBase<L extends Lang = Lang> = {
voice?: LangNarrationOverride<L>
}
type LanguageMetadataKey = keyof LanguageEntryBase
type NarrationLanguageInput = LanguageEntryBase & Record<string, unknown>
type NarrationLanguageInput<L extends Lang = Lang> = LanguageEntryBase<L> &
Record<string, unknown>
type LanguageEntry<C extends Record<string, CueMapValue>> = LanguageEntryBase &
C
@@ -331,14 +334,14 @@ type AllCues<M extends Partial<Record<Lang, NarrationLanguageInput>>> =
type LanguagesMap<M extends Partial<Record<Lang, NarrationLanguageInput>>> =
M & {
[L in LanguageRootKeys<M>]-?: {
voice?: LangNarrationOverride
voice?: LangNarrationOverride<L>
} & {
[K in keyof OmitLanguageMetadata<NonNullable<M[L]>>]: CueMapValue
} & AllCues<M>
}
type NarrationInput<M extends Partial<Record<Lang, NarrationLanguageInput>>> = {
voice?: TopLevelVoiceConfig
voice?: TopLevelVoiceConfig<LanguageRootKeys<M>>
} & LanguagesMap<M>
/**
@@ -392,7 +395,7 @@ export function createNarration<
const languages = normalizeLanguagesInput(input)
return buildCuesFromInput(
input.voice ?? { name: voices.Sophie },
input.voice ?? { name: defaultBuiltInVoice },
languages
) as Cues<AllCues<M>>
}
@@ -649,8 +652,10 @@ function voiceToKeyString(voice: VoiceKey | CustomVoiceRef): string {
return voice
}
function normalizeLanguagesInput(
input: NarrationInput<Partial<Record<Lang, NarrationLanguageInput>>>
function normalizeLanguagesInput<
M extends Partial<Record<Lang, NarrationLanguageInput>>,
>(
input: NarrationInput<M>
): Partial<Record<Lang, LanguageEntry<Record<string, CueMapValue>>>> {
const languages: Partial<
Record<Lang, LanguageEntry<Record<string, CueMapValue>>>
@@ -674,7 +679,7 @@ function normalizeLanguagesInput(
}
function buildCuesFromInput(
topVoice: TopLevelVoiceConfig,
topVoice: AnyTopLevelVoiceConfig,
languages: Partial<Record<Lang, LanguageEntry<Record<string, CueMapValue>>>>
): Cues<Record<string, CueMapValue>> {
const langs = Object.keys(languages) as Lang[]
@@ -949,7 +954,7 @@ async function entryToVideoTranslation(
* provider setting comes from this one config.
*/
export function resolveVoiceMeta(
config: TopLevelVoiceConfig | LangNarrationOverride
config: AnyTopLevelVoiceConfig | AnyLangNarrationOverride
): { name: VoiceKey | CustomVoiceRef; meta: VoiceLanguageMeta } {
const name = config.name
const seed = 'seed' in config ? config.seed : undefined
@@ -1038,8 +1043,8 @@ function prewarmNarrationMedia(
export function buildLocalizedNarrationCues(
narration: NormalizedNarration,
voiceByLang: Partial<Record<string, LangNarrationOverride>>,
defaultVoice: TopLevelVoiceConfig | LangNarrationOverride | undefined,
voiceByLang: Partial<Record<string, AnyLangNarrationOverride>>,
defaultVoice: AnyTopLevelVoiceConfig | AnyLangNarrationOverride | undefined,
// The `.screenci` script media paths are resolved relative to. When provided,
// every file-backed cue's media is pre-warmed (hashed) now, before the
// recording clock starts, so the cue's start() reuses the cached hash instead
@@ -1101,7 +1106,7 @@ export function buildLocalizedNarrationCues(
const value = narration.seedByLang[lang as Lang]![cueName]!
const config = (value.kind === 'text' ? value.voice : undefined) ??
voiceByLang[lang] ??
defaultVoice ?? { name: voices.Sophie }
defaultVoice ?? { name: defaultBuiltInVoice }
const { name, meta } = resolveVoiceMeta(config)
return { value, voice: name, meta }
}
+1 -1
View File
@@ -155,7 +155,7 @@ describe('generateExampleVideo', () => {
})`)
// No per-language keys: the example does not declare a second language.
expect(source).not.toContain(' es: {')
// Narration defaults to the built-in voice (Sophie), so the example carries no
// Narration defaults to the built-in voice (Ava), so the example carries no
// redundant voice config and does not import `voices`.
expect(source).not.toContain('video.use({')
expect(source).not.toContain('voices')
+25
View File
@@ -101,6 +101,31 @@ describe('video.localize typed fixtures', () => {
})('T', async () => {})
})
it('rejects an unavailable Russian per-language built-in voice', () => {
video.localize({
voice: {
// @ts-expect-error this built-in voice is not available for Russian
ru: { name: voices.Sophie },
},
narration: { en: { intro: 'Hi' }, ru: { intro: 'Privet' } },
})('T', async () => {})
})
it('rejects an unavailable Russian per-cue built-in voice', () => {
video.localize({
narration: {
en: { intro: 'Hi' },
ru: {
intro: {
cue: 'Privet',
// @ts-expect-error this built-in voice is not available for Russian
voice: { name: voices.Sophie },
},
},
},
})('T', async () => {})
})
it('accepts a per-cue synthesis language on a { cue } value', () => {
video.localize({
narration: {
+18 -14
View File
@@ -1,5 +1,8 @@
import { supportedLanguages, type Lang } from './voices.js'
import type { LangNarrationOverride } from './voiceConfig.js'
import type {
AnyLangNarrationOverride,
LangNarrationOverride,
} from './voiceConfig.js'
import type { OverlayCrop, SourceTrimPoint } from './events.js'
import type { TimelineOffset } from './timelineOffset.js'
import { validateCrop, resolveSourceTrim } from './sourceTrim.js'
@@ -9,7 +12,8 @@ import { validateCrop, resolveSourceTrim } from './sourceTrim.js'
* sit on a per-language or per-cue voice), discriminated by voice name like the
* narration voice overrides.
*/
export type VoiceConfig = LangNarrationOverride
export type VoiceConfig<L extends Lang = Lang> = LangNarrationOverride<L>
type AnyVoiceConfig = AnyLangNarrationOverride
/**
* How a localized video/screenshot is recorded.
@@ -40,16 +44,16 @@ type LocalizeNarrationMediaFields = {
end?: TimelineOffset
language?: never
}
export type LocalizeNarrationValue =
export type LocalizeNarrationValue<L extends Lang = Lang> =
| string
| { cue: string; voice?: VoiceConfig; language?: Lang; volume?: number }
| { cue: string; voice?: VoiceConfig<L>; language?: Lang; volume?: number }
| ({ media: string } & LocalizeNarrationMediaFields)
| ({ path: string } & LocalizeNarrationMediaFields)
/** Seeded narration: language -> (cue name -> value). */
export type NarrationByLang = Partial<
Record<Lang, Record<string, LocalizeNarrationValue>>
>
export type NarrationByLang = Partial<{
[L in Lang]: Record<string, LocalizeNarrationValue<L>>
}>
/** Seeded values: language -> (field name -> string). */
export type ValuesByLang = Partial<Record<Lang, Record<string, string>>>
@@ -61,7 +65,7 @@ export type ValuesByLang = Partial<Record<Lang, Record<string, string>>>
* config-for-all-languages form here: the all-languages default belongs in
* `use`, so the localize `voice` only carries per-language overrides.
*/
export type LocalizeVoiceSpec = Partial<Record<Lang, VoiceConfig>>
export type LocalizeVoiceSpec = Partial<{ [L in Lang]: VoiceConfig<L> }>
/**
* The internal localization spec assembled from the per-feature builders
@@ -99,7 +103,7 @@ export type NormalizedCueValue =
| {
kind: 'text'
text: string
voice?: VoiceConfig
voice?: AnyVoiceConfig
language?: Lang
volume?: number
}
@@ -143,7 +147,7 @@ export type NormalizedLocalize = {
narration: NormalizedNarration
values: NormalizedValues
/** Per-language localize `voice` overrides (absent language = use config default). */
voiceByLang: Partial<Record<Lang, VoiceConfig>>
voiceByLang: Partial<Record<Lang, AnyVoiceConfig>>
}
const SUPPORTED_LANGUAGE_SET = new Set<string>(supportedLanguages)
@@ -176,9 +180,9 @@ function collectSeededNames(
return out
}
export function normalizeCueValue(
export function normalizeCueValue<L extends Lang>(
name: string,
value: LocalizeNarrationValue
value: LocalizeNarrationValue<L>
): NormalizedCueValue {
if (typeof value === 'string') {
return { kind: 'text', text: value }
@@ -322,7 +326,7 @@ function normalizeValues(input: ValuesByLang | undefined): {
function normalizeVoice(
voice: LocalizeVoiceSpec | undefined,
languages: readonly string[]
): Partial<Record<Lang, VoiceConfig>> {
): Partial<Record<Lang, AnyVoiceConfig>> {
if (voice === undefined) return {}
// Per-language overrides only. Languages omitted from the map fall back to the
// config/global default voice set via `use`, so the map may be partial; it may
@@ -335,7 +339,7 @@ function normalizeVoice(
)} that are not part of this localization (${languages.join(', ')}).`
)
}
const out: Partial<Record<Lang, VoiceConfig>> = {}
const out: Partial<Record<Lang, AnyVoiceConfig>> = {}
for (const [lang, config] of Object.entries(voice)) {
if (config !== undefined) out[lang as Lang] = config
}
+1 -1
View File
@@ -302,7 +302,7 @@ describe('buildNarrationMarkers', () => {
'en',
])
await markers.intro()
expect(voiceOf('en')).toBe(voices.Sophie)
expect(voiceOf('en')).toBe(voices.Ava)
})
})
+7 -4
View File
@@ -4,12 +4,15 @@ import {
type LocalizeNarrationValue,
type NormalizedCueValue,
type NormalizedNarration,
type VoiceConfig,
} from './localize.js'
import type { NormalizedFeature } from './declare.js'
import type { Lang } from './voices.js'
import type { ValuesOverrides } from './runtimeMode.js'
import type { TopLevelVoiceConfig } from './voiceConfig.js'
import type {
AnyLangNarrationOverride,
AnyTopLevelVoiceConfig,
TopLevelVoiceConfig,
} from './voiceConfig.js'
import type { RenderOptions } from './types.js'
/**
@@ -73,8 +76,8 @@ function featureToNormalizedNarration(
export function buildNarrationMarkers(
narration: NormalizedFeature<LocalizeNarrationValue> | null | undefined,
languages: string[],
defaultVoice?: TopLevelVoiceConfig,
voiceByLang: Partial<Record<string, VoiceConfig>> = {},
defaultVoice?: AnyTopLevelVoiceConfig,
voiceByLang: Partial<Record<string, AnyLangNarrationOverride>> = {},
// The `.screenci` script that media paths resolve against. When provided, the
// file-backed cues' media is pre-warmed (hashed) up front so their start()
// does not pay the read on the recording timeline. Omitted outside recording.
+68
View File
@@ -10,6 +10,7 @@ import {
setCurrentZoomViewport,
} from './autoZoom.js'
import { DEFAULT_SCROLL_CENTERING } from './defaults.js'
import { hide, setActiveHideRecorder } from './hide.js'
import { resetZoom, zoomTo } from './manualZoom.js'
type MockDoc = {
@@ -135,12 +136,14 @@ describe('manual zoom', () => {
})
setCurrentZoomViewport(null)
setActiveAutoZoomRecorder(NOOP_EVENT_RECORDER)
setActiveHideRecorder(NOOP_EVENT_RECORDER)
setActiveZoomPage(null)
})
afterEach(() => {
vi.useRealTimers()
setActiveAutoZoomRecorder(NOOP_EVENT_RECORDER)
setActiveHideRecorder(NOOP_EVENT_RECORDER)
setActiveZoomPage(null)
})
@@ -175,6 +178,36 @@ describe('manual zoom', () => {
).toBe('focusChange')
})
it('updates manual zoom inside hide() without recording a focusChange input', async () => {
const recorder = new EventRecorder()
recorder.start()
setActiveAutoZoomRecorder(recorder)
setActiveHideRecorder(recorder)
const locator = makeLocatorMock({
rect: { x: 900, y: 500, width: 80, height: 40 },
viewport: { width: 1280, height: 720 },
scrollSize: { width: 1280, height: 720 },
})
const promise = hide(async () => {
await zoomTo(locator, {
amount: 0.5,
centering: 1,
duration: 300,
})
})
await vi.runAllTimersAsync()
await promise
const events = recorder.getEvents()
expect(events).toHaveLength(3)
expect(events[1]).toMatchObject({ type: 'hideStart' })
expect(events[2]).toMatchObject({ type: 'hideEnd' })
expect(getAutoZoomState().mode).toBe('manual')
expect(getAutoZoomState().currentZoomViewport).not.toBeNull()
})
it('computes a stable point target for zoomTo({ x, y })', async () => {
const recorder = new EventRecorder()
recorder.start()
@@ -285,6 +318,41 @@ describe('manual zoom', () => {
expect(getAutoZoomState().mode).toBe('idle')
})
it('resets zoom inside hide() without recording a focusChange input', async () => {
const recorder = new EventRecorder()
recorder.start()
setActiveAutoZoomRecorder(recorder)
setActiveHideRecorder(recorder)
setAutoZoomState({
insideAutoZoom: false,
mode: 'manual',
options: {},
scrollCentering: DEFAULT_SCROLL_CENTERING,
currentZoomViewport: {
focusPoint: { x: 640, y: 360 },
viewportSize: { width: 1280, height: 720 },
optimalOffset: { x: 0, y: 0 },
end: {
pointPx: { x: 640, y: 360 },
size: { widthPx: 640, heightPx: 360 },
},
},
})
const promise = hide(async () => {
await resetZoom({ duration: 300, postZoomDelay: 0 })
})
await vi.runAllTimersAsync()
await promise
const events = recorder.getEvents()
expect(events).toHaveLength(3)
expect(events[1]).toMatchObject({ type: 'hideStart' })
expect(events[2]).toMatchObject({ type: 'hideEnd' })
expect(getAutoZoomState().mode).toBe('idle')
})
it('throws when autoZoom starts while manual zoom is active, and vice versa', async () => {
setActiveZoomPage(makePageMock())
+8 -3
View File
@@ -2,6 +2,7 @@ import type { Locator, Page } from '@playwright/test'
import { DEFAULT_ZOOM_OPTIONS } from './defaults.js'
import { ScreenciError } from './errors.js'
import type { FocusChangeEvent } from './events.js'
import { isInsideHide } from './hide.js'
import { changeFocus, resolvePointFocusZoom } from './changeFocus.js'
import {
getActiveAutoZoomRecorder,
@@ -157,12 +158,14 @@ export async function zoomTo(
setZoomMode(
result.zoom !== undefined || previousMode === 'manual' ? 'manual' : 'idle'
)
recorder.addInput('focusChange', result.elementRect, [result])
if (!isInsideHide()) {
recorder.addInput('focusChange', result.elementRect, [result])
}
return
}
const result = await zoomToPoint(target, options)
if (result !== undefined) {
if (result !== undefined && !isInsideHide()) {
recorder.addInput('focusChange', undefined, [result])
}
}
@@ -228,5 +231,7 @@ export async function resetZoom(options: AutoZoomOptions = {}): Promise<void> {
})
setZoomMode('idle')
recorder.addInput('focusChange', viewport.elementRect, [result])
if (!isInsideHide()) {
recorder.addInput('focusChange', viewport.elementRect, [result])
}
}
+30 -7
View File
@@ -1,5 +1,6 @@
import type {
ModelVoiceKey,
Lang,
BuiltInVoiceKeyForLang,
ElevenLabsVoiceKey,
CustomVoiceRef,
ModelType,
@@ -34,14 +35,16 @@ type ElevenLabsVoiceConfig = ElevenLabsVoiceSettings & {
pacing?: never
}
type BuiltInVoiceName<L extends Lang = Lang> = BuiltInVoiceKeyForLang<L>
/**
* Default voice shared across all languages. `seed` is not allowed here use a
* per-language voice override instead.
*/
export type TopLevelVoiceConfig =
export type TopLevelVoiceConfig<L extends Lang = Lang> =
| ElevenLabsVoiceConfig
| {
name: ModelVoiceKey
name: BuiltInVoiceName<L>
/** Speaking style prompt for expressive synthesis. Business tier only. Implies `expressive` model type. */
style: string
/** Can be omitted when `style` is set — `expressive` is implied. Business tier only. */
@@ -59,7 +62,7 @@ export type TopLevelVoiceConfig =
pacing?: string
}
| {
name: ModelVoiceKey
name: BuiltInVoiceName<L>
style?: never
accent?: never
/** Speaking rate for consistent synthesis. Valid range: 0.25 to 2. */
@@ -76,7 +79,7 @@ export type TopLevelVoiceConfig =
* use expressive/consistent controls, ElevenLabs voices use the numeric
* `eleven_multilingual_v2` controls.
*/
export type LangNarrationOverride =
export type LangNarrationOverride<L extends Lang = Lang> =
| (ElevenLabsVoiceConfig & {
/**
* Integer seed included in the audio cache key and forwarded to ElevenLabs.
@@ -85,7 +88,7 @@ export type LangNarrationOverride =
seed?: number
})
| {
name: ModelVoiceKey
name: BuiltInVoiceName<L>
/**
* Integer seed included in the audio cache key. A different seed always forces
* regeneration. Consistent output is not guaranteed across all voice types.
@@ -108,7 +111,7 @@ export type LangNarrationOverride =
pacing?: string
}
| {
name: ModelVoiceKey
name: BuiltInVoiceName<L>
/**
* Integer seed included in the audio cache key. A different seed always forces
* regeneration. Consistent output is not guaranteed across all voice types.
@@ -121,3 +124,23 @@ export type LangNarrationOverride =
/** TTS model type — `modelTypes.expressive` or `modelTypes.consistent`. Defaults to `consistent`. */
modelType?: Exclude<ModelType, 'expressive'> | undefined
}
/**
* Union of every language-specific top-level voice config. Unlike
* `TopLevelVoiceConfig<Lang>`, this does not collapse to the Russian-safe
* subset; it preserves each language's valid config as a separate branch.
*/
export type AnyTopLevelVoiceConfig = {
[L in Lang]: TopLevelVoiceConfig<L>
}[Lang]
/**
* Union of every language-specific per-language narration override.
*
* Useful for normalized runtime shapes that store already-resolved per-language
* voice configs without reapplying the shared-subset narrowing used by public
* APIs that span multiple languages.
*/
export type AnyLangNarrationOverride = {
[L in Lang]: LangNarrationOverride<L>
}[Lang]
+37 -2
View File
@@ -25,8 +25,11 @@ export type ModelType = (typeof modelTypes)[keyof typeof modelTypes]
/**
* Named voices available for the narration voice (`renderOptions.narration`).
*
* Built-in voices are language-agnostic; set a default and override per language
* with `voices`:
* Built-in voices share one public catalog across supported languages. Most work
* everywhere; when a language has a smaller built-in subset, TypeScript narrows
* the allowed names for that language automatically.
*
* Set a default and override per language with `voices`:
*
* ```ts
* video.use({
@@ -261,6 +264,38 @@ export const supportedLanguages = supportedBaseLanguageCodes
/** Union of supported language codes, e.g. `'en' | 'fi' | 'cmn'`. */
export type Lang = SupportedBaseLanguageCode
/**
* Built-in voice names available for Russian narration.
*
* Russian has a smaller built-in voice subset than most supported languages, so
* the SDK narrows Russian voice configs to these names.
*/
export const russianBuiltInVoices = [
voices.Ava,
voices.Daniel,
voices.Emma,
voices.Leo,
voices.Lily,
voices.Max,
voices.Miles,
voices.Nora,
] as const
export type RussianBuiltInVoiceKey = (typeof russianBuiltInVoices)[number]
/**
* Built-in voice names available for a given narration language.
*
* For a language union, this resolves to the shared subset that is valid for
* every language in the union.
*/
export type BuiltInVoiceKeyForLang<L extends Lang> = 'ru' extends L
? RussianBuiltInVoiceKey
: ModelVoiceKey
/** Built-in fallback voice used when no narration voice is configured. */
export const defaultBuiltInVoice = voices.Ava
/**
* A reference to a local audio or video file for ElevenLabs Instant Voice Cloning.
* Build one with `voices.elevenlabs({ path })` and pass it as the voice `name`