From bdcc61e223c624c31532ea22f742bdfd4cbc673a Mon Sep 17 00:00:00 2001 From: Olli Paloviita Date: Mon, 6 Jul 2026 00:54:35 +0300 Subject: [PATCH] chore: update screenci --- cli.auth.spec.ts | 4 + cli.config-lite.spec.ts | 4 + cli.init.spec.ts | 4 + cli.misc.spec.ts | 4 + cli.record.spec.ts | 407 ++++++++++++- cli.test.spec.ts | 4 + cli.ts | 567 +++++++++++++++--- cli.upload-helpers.spec.ts | 32 + cli.urls.spec.ts | 4 + docs/cli.mdx | 9 +- docs/languages.md | 5 + docs/narration.md | 13 +- docs/video-script-basics.md | 2 +- .../camera-and-zooming.screenci.ts | 2 +- docs/video-sources/editor.screenci.ts | 2 +- docs/video-sources/languages.screenci.ts | 2 +- docs/video-sources/narration.screenci.ts | 2 +- .../public-urls-and-embeds.screenci.ts | 2 +- src/anonSession.ts | 1 + src/cue.spec.ts | 10 +- src/cue.test-d.ts | 28 + src/cue.ts | 33 +- src/init.spec.ts | 2 +- src/localize.test-d.ts | 25 + src/localize.ts | 32 +- src/localizeRuntime.spec.ts | 2 +- src/localizeRuntime.ts | 11 +- src/manualZoom.spec.ts | 68 +++ src/manualZoom.ts | 11 +- src/voiceConfig.ts | 37 +- src/voices.ts | 39 +- 31 files changed, 1215 insertions(+), 153 deletions(-) diff --git a/cli.auth.spec.ts b/cli.auth.spec.ts index 2fff29f..54fae22 100644 --- a/cli.auth.spec.ts +++ b/cli.auth.spec.ts @@ -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) { diff --git a/cli.config-lite.spec.ts b/cli.config-lite.spec.ts index 9bf5c53..b8c0dcb 100644 --- a/cli.config-lite.spec.ts +++ b/cli.config-lite.spec.ts @@ -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) { diff --git a/cli.init.spec.ts b/cli.init.spec.ts index 8f3faeb..875c99b 100644 --- a/cli.init.spec.ts +++ b/cli.init.spec.ts @@ -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) { diff --git a/cli.misc.spec.ts b/cli.misc.spec.ts index 77ca3ca..250090a 100644 --- a/cli.misc.spec.ts +++ b/cli.misc.spec.ts @@ -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) { diff --git a/cli.record.spec.ts b/cli.record.spec.ts index 9f98c49..053e583 100644 --- a/cli.record.spec.ts +++ b/cli.record.spec.ts @@ -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 () => { diff --git a/cli.test.spec.ts b/cli.test.spec.ts index df48a4e..6adbd15 100644 --- a/cli.test.spec.ts +++ b/cli.test.spec.ts @@ -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) { diff --git a/cli.ts b/cli.ts index 92d650a..05db1ed 100644 --- a/cli.ts +++ b/cli.ts @@ -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() + 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 { - 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() + + 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 { 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 ` []` 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 { + 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 = {} +): Promise<{ release: () => Promise }> { + 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 { // 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() } }) diff --git a/cli.upload-helpers.spec.ts b/cli.upload-helpers.spec.ts index 05c874c..f24de96 100644 --- a/cli.upload-helpers.spec.ts +++ b/cli.upload-helpers.spec.ts @@ -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]' + ) + }) + }) }) diff --git a/cli.urls.spec.ts b/cli.urls.spec.ts index d360ec5..9948636 100644 --- a/cli.urls.spec.ts +++ b/cli.urls.spec.ts @@ -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) { diff --git a/docs/cli.mdx b/docs/cli.mdx index b8b7751..04a5514 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -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 diff --git a/docs/languages.md b/docs/languages.md index 6e5c452..f29f780 100644 --- a/docs/languages.md +++ b/docs/languages.md @@ -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: diff --git a/docs/narration.md b/docs/narration.md index 3521a6c..1d8f32f 100644 --- a/docs/narration.md +++ b/docs/narration.md @@ -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 | diff --git a/docs/video-script-basics.md b/docs/video-script-basics.md index 837b4ce..139ab3d 100644 --- a/docs/video-script-basics.md +++ b/docs/video-script-basics.md @@ -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. diff --git a/docs/video-sources/camera-and-zooming.screenci.ts b/docs/video-sources/camera-and-zooming.screenci.ts index 120703c..05c23f7 100644 --- a/docs/video-sources/camera-and-zooming.screenci.ts +++ b/docs/video-sources/camera-and-zooming.screenci.ts @@ -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: { diff --git a/docs/video-sources/editor.screenci.ts b/docs/video-sources/editor.screenci.ts index 624392e..e882dd3 100644 --- a/docs/video-sources/editor.screenci.ts +++ b/docs/video-sources/editor.screenci.ts @@ -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' }, }, }, }) diff --git a/docs/video-sources/languages.screenci.ts b/docs/video-sources/languages.screenci.ts index a54e9ca..0f2e50a 100644 --- a/docs/video-sources/languages.screenci.ts +++ b/docs/video-sources/languages.screenci.ts @@ -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' }, }, }, }) diff --git a/docs/video-sources/narration.screenci.ts b/docs/video-sources/narration.screenci.ts index 8140dcd..2075a78 100644 --- a/docs/video-sources/narration.screenci.ts +++ b/docs/video-sources/narration.screenci.ts @@ -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' }, }, }, }) diff --git a/docs/video-sources/public-urls-and-embeds.screenci.ts b/docs/video-sources/public-urls-and-embeds.screenci.ts index 116a6c4..e214af4 100644 --- a/docs/video-sources/public-urls-and-embeds.screenci.ts +++ b/docs/video-sources/public-urls-and-embeds.screenci.ts @@ -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' }, }, }, }) diff --git a/src/anonSession.ts b/src/anonSession.ts index 350ff7d..890a021 100644 --- a/src/anonSession.ts +++ b/src/anonSession.ts @@ -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 diff --git a/src/cue.spec.ts b/src/cue.spec.ts index 8dd9912..b97cb66 100644 --- a/src/cue.spec.ts +++ b/src/cue.spec.ts @@ -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', }) }) diff --git a/src/cue.test-d.ts b/src/cue.test-d.ts index 9fe7aba..276c5cc 100644 --- a/src/cue.test-d.ts +++ b/src/cue.test-d.ts @@ -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 }, diff --git a/src/cue.ts b/src/cue.ts index 4e121cf..d089682 100644 --- a/src/cue.ts +++ b/src/cue.ts @@ -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 = ( * 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 = { + voice?: LangNarrationOverride } type LanguageMetadataKey = keyof LanguageEntryBase -type NarrationLanguageInput = LanguageEntryBase & Record +type NarrationLanguageInput = LanguageEntryBase & + Record type LanguageEntry> = LanguageEntryBase & C @@ -331,14 +334,14 @@ type AllCues>> = type LanguagesMap>> = M & { [L in LanguageRootKeys]-?: { - voice?: LangNarrationOverride + voice?: LangNarrationOverride } & { [K in keyof OmitLanguageMetadata>]: CueMapValue } & AllCues } type NarrationInput>> = { - voice?: TopLevelVoiceConfig + voice?: TopLevelVoiceConfig> } & LanguagesMap /** @@ -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> } @@ -649,8 +652,10 @@ function voiceToKeyString(voice: VoiceKey | CustomVoiceRef): string { return voice } -function normalizeLanguagesInput( - input: NarrationInput>> +function normalizeLanguagesInput< + M extends Partial>, +>( + input: NarrationInput ): Partial>>> { const languages: Partial< Record>> @@ -674,7 +679,7 @@ function normalizeLanguagesInput( } function buildCuesFromInput( - topVoice: TopLevelVoiceConfig, + topVoice: AnyTopLevelVoiceConfig, languages: Partial>>> ): Cues> { 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>, - defaultVoice: TopLevelVoiceConfig | LangNarrationOverride | undefined, + voiceByLang: Partial>, + 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 } } diff --git a/src/init.spec.ts b/src/init.spec.ts index 42965ec..5af4c46 100644 --- a/src/init.spec.ts +++ b/src/init.spec.ts @@ -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') diff --git a/src/localize.test-d.ts b/src/localize.test-d.ts index c405a40..8a9fc5c 100644 --- a/src/localize.test-d.ts +++ b/src/localize.test-d.ts @@ -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: { diff --git a/src/localize.ts b/src/localize.ts index d1898b3..f513e80 100644 --- a/src/localize.ts +++ b/src/localize.ts @@ -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 = LangNarrationOverride +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 = | string - | { cue: string; voice?: VoiceConfig; language?: Lang; volume?: number } + | { cue: string; voice?: VoiceConfig; language?: Lang; volume?: number } | ({ media: string } & LocalizeNarrationMediaFields) | ({ path: string } & LocalizeNarrationMediaFields) /** Seeded narration: language -> (cue name -> value). */ -export type NarrationByLang = Partial< - Record> -> +export type NarrationByLang = Partial<{ + [L in Lang]: Record> +}> /** Seeded values: language -> (field name -> string). */ export type ValuesByLang = Partial>> @@ -61,7 +65,7 @@ export type ValuesByLang = Partial>> * 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> +export type LocalizeVoiceSpec = Partial<{ [L in Lang]: VoiceConfig }> /** * 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> + voiceByLang: Partial> } const SUPPORTED_LANGUAGE_SET = new Set(supportedLanguages) @@ -176,9 +180,9 @@ function collectSeededNames( return out } -export function normalizeCueValue( +export function normalizeCueValue( name: string, - value: LocalizeNarrationValue + value: LocalizeNarrationValue ): 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> { +): Partial> { 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> = {} + const out: Partial> = {} for (const [lang, config] of Object.entries(voice)) { if (config !== undefined) out[lang as Lang] = config } diff --git a/src/localizeRuntime.spec.ts b/src/localizeRuntime.spec.ts index f4512bd..5badf56 100644 --- a/src/localizeRuntime.spec.ts +++ b/src/localizeRuntime.spec.ts @@ -302,7 +302,7 @@ describe('buildNarrationMarkers', () => { 'en', ]) await markers.intro() - expect(voiceOf('en')).toBe(voices.Sophie) + expect(voiceOf('en')).toBe(voices.Ava) }) }) diff --git a/src/localizeRuntime.ts b/src/localizeRuntime.ts index c421abb..469bfdb 100644 --- a/src/localizeRuntime.ts +++ b/src/localizeRuntime.ts @@ -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 | null | undefined, languages: string[], - defaultVoice?: TopLevelVoiceConfig, - voiceByLang: Partial> = {}, + defaultVoice?: AnyTopLevelVoiceConfig, + voiceByLang: Partial> = {}, // 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. diff --git a/src/manualZoom.spec.ts b/src/manualZoom.spec.ts index e1f48e7..db91606 100644 --- a/src/manualZoom.spec.ts +++ b/src/manualZoom.spec.ts @@ -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()) diff --git a/src/manualZoom.ts b/src/manualZoom.ts index d897aba..e9972f1 100644 --- a/src/manualZoom.ts +++ b/src/manualZoom.ts @@ -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 { }) setZoomMode('idle') - recorder.addInput('focusChange', viewport.elementRect, [result]) + if (!isInsideHide()) { + recorder.addInput('focusChange', viewport.elementRect, [result]) + } } diff --git a/src/voiceConfig.ts b/src/voiceConfig.ts index 263fd68..147e294 100644 --- a/src/voiceConfig.ts +++ b/src/voiceConfig.ts @@ -1,5 +1,6 @@ import type { - ModelVoiceKey, + Lang, + BuiltInVoiceKeyForLang, ElevenLabsVoiceKey, CustomVoiceRef, ModelType, @@ -34,14 +35,16 @@ type ElevenLabsVoiceConfig = ElevenLabsVoiceSettings & { pacing?: never } +type BuiltInVoiceName = BuiltInVoiceKeyForLang + /** * Default voice shared across all languages. `seed` is not allowed here — use a * per-language voice override instead. */ -export type TopLevelVoiceConfig = +export type TopLevelVoiceConfig = | ElevenLabsVoiceConfig | { - name: ModelVoiceKey + name: BuiltInVoiceName /** 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 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 = | (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 /** * 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 /** * 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 | undefined } + +/** + * Union of every language-specific top-level voice config. Unlike + * `TopLevelVoiceConfig`, 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 +}[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 +}[Lang] diff --git a/src/voices.ts b/src/voices.ts index d036bb8..9a4a17c 100644 --- a/src/voices.ts +++ b/src/voices.ts @@ -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 = '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`