feat(screenci): add cue localization and git helpers

This commit is contained in:
Olli Paloviita
2026-06-12 20:11:08 +03:00
parent 75bfe5bc60
commit 23d5fdbd7e
15 changed files with 589 additions and 26 deletions
+9
View File
@@ -125,6 +125,11 @@ export default defineConfig({
ScreenCI manages `testDir`, `testMatch`, and `retries` for you. Most other
Playwright config still passes through.
If you keep local runtime secrets in an env file, point `envFile` at it or use
the project `.env`. ScreenCI loads that file automatically for CLI commands.
That is also the right place for BYOK-style secrets such as
`ELEVENLABS_API_KEY`. ScreenCI does not store raw API keys from your env file.
## Authoring helpers
| Export | What it does |
@@ -151,3 +156,7 @@ Playwright config still passes through.
When `SCREENCI_SECRET` is configured, `screenci record` uploads the output to
ScreenCI for rendering, narration generation, and hosted delivery.
For narration authoring, keep cues short and usually one sentence at a time.
That makes overlap timing easier to manage and should reduce TTS regeneration
cost when only one line changes.
+2 -2
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
import { main } from '../dist/cli.js'
import { logCliError, main } from '../dist/cli.js'
main().catch((error) => {
console.error('Error:', error.message)
logCliError(error)
process.exit(1)
})
+36 -4
View File
@@ -541,10 +541,20 @@ describe('CLI', () => {
return mockChildProcess as unknown as ChildProcess
})
const { main } = await import('./cli')
const { main, logCliError } = await import('./cli')
await expect(main()).rejects.toThrow('Playwright exited with code 1')
const error = await main().catch((err) => err)
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toBe('Playwright exited with code 1')
loggerErrorSpy.mockClear()
loggerInfoSpy.mockClear()
logCliError(error)
expect(loggerErrorSpy).toHaveBeenCalledWith(
'Playwright exited with code 1'
)
expect(loggerInfoSpy).toHaveBeenCalledWith(
expect.stringContaining('screenci test --mock-record')
)
@@ -553,6 +563,9 @@ describe('CLI', () => {
'https://screenci.com/docs/reference/cli/#--mock-record'
)
)
expect(loggerErrorSpy.mock.invocationCallOrder[0]).toBeLessThan(
loggerInfoSpy.mock.invocationCallOrder[0]
)
})
it('surfaces the first Playwright discovery error and snippet instead of raw JSON', async () => {
@@ -585,11 +598,30 @@ describe('CLI', () => {
return mockChildProcess as unknown as ChildProcess
})
const { main } = await import('./cli')
const { main, logCliError } = await import('./cli')
await expect(main()).rejects.toThrow(
const error = await main().catch((err) => err)
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain(
'Error: [screenci] Asset "badge" (./assets/brand-badge.svg) is an image asset and must not provide audio. Use durationMs instead.\n\nat styled-assets.video.ts:24'
)
loggerErrorSpy.mockClear()
loggerInfoSpy.mockClear()
logCliError(error)
expect(loggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Error: [screenci] Asset "badge" (./assets/brand-badge.svg) is an image asset and must not provide audio. Use durationMs instead.\n\nat styled-assets.video.ts:24'
)
)
expect(loggerInfoSpy).toHaveBeenCalledWith(
expect.stringContaining('screenci test --mock-record')
)
expect(loggerErrorSpy.mock.invocationCallOrder[0]).toBeLessThan(
loggerInfoSpy.mock.invocationCallOrder[0]
)
})
})
+91
View File
@@ -600,6 +600,7 @@ describe('CLI', () => {
expect(result).toEqual({
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: false,
failedVideoNames: [],
failedVideoMessages: [],
@@ -611,6 +612,88 @@ describe('CLI', () => {
)
})
it('forwards ELEVENLABS_API_KEY during upload requests when configured', async () => {
process.env.ELEVENLABS_API_KEY = 'elevenlabs-byok-key'
mockReaddir.mockResolvedValue(['demo-video'])
mockReadFile.mockImplementation(async (path: string | URL) => {
const pathString = String(path)
if (pathString.endsWith('package.json')) {
return JSON.stringify({ version: '0.0.32' })
}
if (pathString.endsWith('record-upload.config.ts')) {
return "export default { projectName: 'Test Project' }"
}
if (pathString.endsWith('data.json')) {
return JSON.stringify({ events: [], metadata: { videoName: 'Demo' } })
}
return ''
})
mockExistsSync.mockImplementation(
(path: string) =>
path.endsWith('test-fixtures/record-upload.config.ts') ||
path.endsWith('data.json') ||
path.endsWith('recording.mp4')
)
mockFetch.mockImplementation(async (input: string | URL) => {
const url = String(input)
if (url.endsWith('/cli/upload/start')) {
return {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({
recordingId: 'recording_123',
projectId: 'project_123',
}),
text: vi.fn().mockResolvedValue(''),
}
}
if (url.endsWith('/cli/upload/recording_123/recording')) {
return {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({}),
text: vi.fn().mockResolvedValue(''),
}
}
return {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({}),
text: vi.fn().mockResolvedValue(''),
}
})
const { uploadRecordings } = await import('./cli')
await uploadRecordings(
'/repo/.screenci',
'Test Project',
'https://api.screenci.test',
'test-secret'
)
expect(mockFetch).toHaveBeenCalledWith(
'https://api.screenci.test/cli/upload/start',
expect.objectContaining({
headers: expect.objectContaining({
'X-ScreenCI-Secret': 'test-secret',
'X-ElevenLabs-Api-Key': 'elevenlabs-byok-key',
}),
})
)
expect(mockFetch).toHaveBeenCalledWith(
'https://api.screenci.test/cli/upload/recording_123/recording',
expect.objectContaining({
headers: expect.objectContaining({
'X-ScreenCI-Secret': 'test-secret',
'X-ElevenLabs-Api-Key': 'elevenlabs-byok-key',
}),
})
)
})
it('uploads completed recordings after partial failure with default policy, then still fails', async () => {
process.argv = [
'node',
@@ -757,6 +840,7 @@ describe('CLI', () => {
expect(result).toEqual({
projectId: null,
recordId: null,
hadFailures: false,
failedVideoNames: [],
failedVideoMessages: [],
@@ -791,6 +875,7 @@ describe('CLI', () => {
expect(result).toEqual({
projectId: null,
recordId: expect.any(String),
hadFailures: true,
failedVideoNames: ['Demo'],
failedVideoMessages: [
@@ -888,6 +973,7 @@ describe('CLI', () => {
expect(result).toEqual({
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: true,
failedVideoNames: ['Demo'],
failedVideoMessages: [
@@ -1004,6 +1090,7 @@ describe('CLI', () => {
expect(result).toEqual({
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: false,
failedVideoNames: [],
failedVideoMessages: [],
@@ -1094,6 +1181,7 @@ describe('CLI', () => {
expect(result).toEqual({
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: true,
failedVideoNames: ['Failed Demo'],
failedVideoMessages: [
@@ -1163,6 +1251,7 @@ describe('CLI', () => {
expect(result).toEqual({
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: false,
failedVideoNames: [],
failedVideoMessages: [],
@@ -1232,6 +1321,7 @@ describe('CLI', () => {
expect(result).toEqual({
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: false,
failedVideoNames: [],
failedVideoMessages: [],
@@ -1332,6 +1422,7 @@ describe('CLI', () => {
expect(result).toEqual({
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: false,
failedVideoNames: [],
failedVideoMessages: [],
+77 -16
View File
@@ -9,7 +9,7 @@ import {
realpathSync,
rmSync,
} from 'fs'
import { createHash } from 'crypto'
import { createHash, randomUUID } from 'crypto'
import { createRequire } from 'module'
import { appendFile, readdir, readFile, stat, writeFile } from 'fs/promises'
import { delimiter, dirname, relative as pathRelative, resolve } from 'path'
@@ -369,6 +369,7 @@ type UploadJobResult = {
hadFailure: boolean
videoName: string
failureMessage?: string
recordId: string
}
type UploadProgressStatus = 'success' | 'failure' | 'cancelled'
@@ -394,6 +395,16 @@ class PartialUploadError extends Error {
}
}
class RecordFailureHintError extends Error {
readonly cause: Error
constructor(cause: Error) {
super(cause.message)
this.name = cause.name
this.cause = cause
}
}
function isUploadCancelledError(err: unknown): boolean {
return (
err instanceof UploadCancelledError ||
@@ -406,6 +417,10 @@ function isPartialUploadError(err: unknown): boolean {
return err instanceof PartialUploadError
}
function isRecordFailureHintError(err: unknown): err is RecordFailureHintError {
return err instanceof RecordFailureHintError
}
function isUploadAssetError(err: unknown): boolean {
return err instanceof UploadAssetError
}
@@ -550,13 +565,15 @@ async function uploadRecordingCandidate(
projectName: string,
apiUrl: string,
secret: string,
elevenLabsApiKey: string | undefined,
verbose: boolean,
uploadAbort: ReturnType<typeof createUploadAbortController>,
progressReporter: {
complete: (index: number, status: UploadProgressStatus) => void
info: (message: string) => void
},
progressIndex: number
progressIndex: number,
recordId: string
): Promise<UploadJobResult> {
const { entry, videoName, data, preparedUploadAssets } = candidate
let projectId: string | null = null
@@ -571,6 +588,7 @@ async function uploadRecordingCandidate(
hadFailure: true,
videoName,
failureMessage: `Missing recording.mp4 for "${videoName}"`,
recordId,
}
}
@@ -580,12 +598,16 @@ async function uploadRecordingCandidate(
headers: {
'Content-Type': 'application/json',
'X-ScreenCI-Secret': secret,
...(elevenLabsApiKey
? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
: {}),
},
body: JSON.stringify({
projectName,
videoName,
data,
recordingHash,
recordId,
expectedAssets: preparedUploadAssets.map((asset) => ({
fileHash: asset.fileHash,
size: asset.size,
@@ -612,6 +634,7 @@ async function uploadRecordingCandidate(
text,
secret
),
recordId,
}
}
@@ -664,6 +687,9 @@ async function uploadRecordingCandidate(
'Content-Type': 'video/mp4',
'Content-Length': String(fileStat.size),
'X-ScreenCI-Secret': secret,
...(elevenLabsApiKey
? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
: {}),
},
body: stream as unknown as BodyInit,
signal: uploadAbort.signal,
@@ -679,6 +705,7 @@ async function uploadRecordingCandidate(
hadFailure: true,
videoName,
failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
recordId,
}
}
} finally {
@@ -687,7 +714,7 @@ async function uploadRecordingCandidate(
progressReporter.complete(progressIndex, 'success')
cleanupUploadedRecordingDir(screenciDir, entry)
return { projectId, hadFailure: false, videoName }
return { projectId, hadFailure: false, videoName, recordId }
} catch (err) {
if (isUploadCancelledError(err)) {
progressReporter.complete(progressIndex, 'cancelled')
@@ -701,6 +728,7 @@ async function uploadRecordingCandidate(
hadFailure: true,
videoName,
failureMessage: err instanceof Error ? err.message : String(err),
recordId,
}
}
@@ -710,6 +738,7 @@ async function uploadRecordingCandidate(
hadFailure: true,
videoName,
failureMessage: `Network error uploading "${videoName}": ${err instanceof Error ? err.message : String(err)}`,
recordId,
}
}
}
@@ -1435,11 +1464,13 @@ export async function uploadRecordings(
verbose = false
): Promise<{
projectId: string | null
recordId: string | null
hadFailures: boolean
failedVideoNames: string[]
failedVideoMessages: Array<{ videoName: string; message: string }>
}> {
const uploadAbort = createUploadAbortController('upload')
const recordId = randomUUID()
let entries: string[]
try {
entries = await readdir(screenciDir)
@@ -1447,6 +1478,7 @@ export async function uploadRecordings(
logger.warn('No .screenci directory found, skipping upload')
return {
projectId: null,
recordId: null,
hadFailures: false,
failedVideoNames: [],
failedVideoMessages: [],
@@ -1458,6 +1490,7 @@ export async function uploadRecordings(
}
let firstProjectId: string | null = null
const elevenLabsApiKey = process.env.ELEVENLABS_API_KEY?.trim() || undefined
try {
const candidates = (
@@ -1472,6 +1505,7 @@ export async function uploadRecordings(
if (candidates.length === 0) {
return {
projectId: null,
recordId: null,
hadFailures: false,
failedVideoNames: [],
failedVideoMessages: [],
@@ -1492,10 +1526,12 @@ export async function uploadRecordings(
projectName,
apiUrl,
secret,
elevenLabsApiKey,
verbose,
uploadAbort,
progressReporter,
index
index,
recordId
)
)
)
@@ -1514,6 +1550,7 @@ export async function uploadRecordings(
return {
projectId: firstProjectId,
recordId,
hadFailures,
failedVideoNames,
failedVideoMessages,
@@ -2199,14 +2236,11 @@ export async function main() {
try {
await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose)
} catch (error) {
logRecordFailureHint()
if (
error instanceof Error &&
error.message.startsWith('Playwright exited with code ')
) {
playwrightFailure = error
if (!(error instanceof Error)) throw error
if (error.message.startsWith('Playwright exited with code ')) {
playwrightFailure = new RecordFailureHintError(error)
} else {
throw error
throw new RecordFailureHintError(error)
}
}
@@ -2253,11 +2287,13 @@ export async function main() {
}
let uploadResult: {
projectId: string | null
recordId: string | null
hadFailures: boolean
failedVideoNames: string[]
failedVideoMessages: Array<{ videoName: string; message: string }>
} = {
projectId: null,
recordId: null,
hadFailures: false,
failedVideoNames: [],
failedVideoMessages: [],
@@ -2277,11 +2313,22 @@ export async function main() {
}
const {
projectId,
recordId,
hadFailures,
failedVideoNames,
failedVideoMessages,
} = uploadResult
if (projectId !== null) {
if (recordId !== null && projectId !== null) {
const recordUrl = `${appUrl}/record/${recordId}`
await writeGitHubProjectOutput(recordUrl)
logger.info('')
logger.info(
playwrightFailure !== null
? 'Recording partially succeeded, rendering in progress. Results available at:'
: 'Recording finished, rendering in progress. Results available at:'
)
logger.info(pc.cyan(recordUrl))
} else if (projectId !== null) {
const projectUrl = `${appUrl}/project/${projectId}`
await writeGitHubProjectOutput(projectUrl)
logger.info('')
@@ -2679,6 +2726,7 @@ async function run(
}
function logRecordFailureHint(): void {
logger.info('')
logger.info(
`If ${pc.cyan('screenci test')} works but ${pc.cyan(
'screenci record'
@@ -2687,6 +2735,22 @@ function logRecordFailureHint(): void {
logger.info(`More info: ${pc.cyan(SCREENCI_MOCK_RECORD_DOCS_URL)}`)
}
export function logCliError(error: unknown): void {
if (isPartialUploadError(error)) {
return
}
const errorToLog = isRecordFailureHintError(error) ? error.cause : error
const message =
errorToLog instanceof Error ? errorToLog.message : String(errorToLog)
logger.error(message)
if (isRecordFailureHintError(error)) {
logRecordFailureHint()
}
}
// Only run if this file is being executed directly
// Check if this module is the main module (handles symlinks properly)
const currentFile = fileURLToPath(import.meta.url)
@@ -2700,10 +2764,7 @@ if (
currentFile === realpathSync(mainFile))
) {
main().catch((error) => {
if (isPartialUploadError(error)) {
process.exit(1)
}
logger.error('Error:', error.message)
logCliError(error)
process.exit(1)
})
}
+5
View File
@@ -390,6 +390,11 @@ These commands support `--config <path>`:
If `envFile` is configured in `screenci.config.ts`, the CLI loads it
automatically. Otherwise it falls back to the project `.env`.
That env file is the recommended place to keep local ScreenCI secrets and other
runtime variables your setup needs. For example, if your local workflow depends
on an ElevenLabs BYOK key, keep `ELEVENLABS_API_KEY` there instead of hardcoding
it elsewhere. ScreenCI does not store raw API keys from that file.
## Related pages
- [Configuration](/docs/reference/configuration) for `screenci.config.ts`.
+72 -4
View File
@@ -75,9 +75,40 @@ Use this as a menu, not a template you must fill out. Most projects only need
### Project identity
- `projectName` identifies the project in ScreenCI.
- `envFile` points to the file that holds `SCREENCI_SECRET` and related
variables.
- If `envFile` is omitted, ScreenCI falls back to the usual process environment.
- `envFile` points to the file that holds `SCREENCI_SECRET` and other local
runtime variables your ScreenCI workflow needs.
- If `envFile` is configured, ScreenCI loads it automatically.
- If `envFile` is omitted, ScreenCI falls back to the project `.env`.
For example, keep `SCREENCI_SECRET` there, and keep any local BYOK secrets such
as `ELEVENLABS_API_KEY` there when your local ScreenCI or backend setup depends
on them. ScreenCI does not store raw API keys from your env file.
### Example: `.env` file
A typical local env file looks like this:
```bash
SCREENCI_SECRET=sc_live_your_project_secret
ELEVENLABS_API_KEY=sk_your_elevenlabs_key
GOOGLE_CLOUD_API_KEY=your_google_cloud_key
GOOGLE_VERTEX_SERVICE_ACCOUNT={"project_id":"my-project","client_email":"...","private_key":"..."}
GOOGLE_VERTEX_LOCATION=us-central1
```
Common cases:
- `SCREENCI_SECRET` authenticates `screenci record`, `screenci info`, and
public visibility commands.
- `ELEVENLABS_API_KEY` is required when your narration uses
`voices.elevenlabs({ voiceId })` or custom voice assets.
- `GOOGLE_CLOUD_API_KEY` is used for consistent model-backed narration.
- `GOOGLE_VERTEX_SERVICE_ACCOUNT` and `GOOGLE_VERTEX_LOCATION` are used for
expressive Gemini narration.
Keep adding local runtime secrets here as needed. `screenci.config.ts` only
points to the env file; the actual secret values belong in `.env` or whatever
file you set via `envFile`.
### File locations
@@ -115,6 +146,42 @@ Set shared `renderOptions` under `use` when you want consistent output styling:
Use project-wide render defaults for branding and layout consistency, then
override only the files that need a different look.
### Example: shared `use` defaults
Use `use` when multiple videos should share the same recording, navigation, or
rendering defaults:
```ts
import { defineConfig } from 'screenci'
export default defineConfig({
projectName: 'my-product',
envFile: '.env',
use: {
baseURL: 'https://staging.example.com',
recordOptions: {
aspectRatio: '16:9',
quality: '1080p',
fps: 60,
},
renderOptions: {
narration: {
corner: 'bottom-left',
size: 'medium',
},
output: {
background: {
backgroundCss: 'linear-gradient(135deg, #0f172a 0%, #1d4ed8 100%)',
},
},
},
},
})
```
This keeps every video in the project on the same baseline, so individual files
only need to override the few things that are actually different.
### Playwright integration
ScreenCI passes through most normal Playwright config such as:
@@ -193,4 +260,5 @@ real workflow.
## Related pages
- [CLI](/docs/reference/cli) for how config is discovered and loaded.
for the runtime helpers used inside scripts.
- [Narration and Localization](/docs/narration-and-localization) for cue and
voice authoring.
+79
View File
@@ -51,6 +51,40 @@ Use the cue methods intentionally:
That is the main tool for overlapping speech with UI motion without losing
control of the timeline.
Keep cues small. In practice, one sentence per cue is the safest default for
timing, overlap control, and subtitle readability.
If only one file needs a different narration layout, pair your cue definitions
with `video.use()` instead of changing the whole project:
```ts
import { createNarration, video, voices } from 'screenci'
video.use({
renderOptions: {
narration: {
corner: 'top-right',
},
},
})
const narration = createNarration({
voice: { name: voices.Sophie },
en: {
intro: 'Open the analytics tab.',
summary: 'Review the latest numbers.',
},
})
video('Analytics walkthrough', async ({ page }) => {
await narration.intro.start()
await page.getByRole('tab', { name: 'Analytics' }).click()
await narration.intro.end()
await narration.summary()
})
```
## Add localization
Add more languages by keeping the same cue keys:
@@ -109,6 +143,10 @@ narration.
Use the top-level `voice` as the default and override only the languages that
genuinely need a different voice or delivery profile.
When you use ElevenLabs-specific voices or custom voice assets, keep each cue as
its own sentence-sized unit. That makes re-recording cheaper and avoids paying
to regenerate long blocks when only one line changes.
## Available voices
ScreenCI ships with built-in voices that you can use across supported languages
@@ -239,3 +277,44 @@ Use `modelType` when you need to choose between consistency and expressiveness.
- `consistent` is the safer default for docs and product walkthroughs
- `expressive` is useful when you want a more natural, less uniform delivery
- `expressive` and `style` prompts require the Business tier
## ElevenLabs BYOK and cost control
If your narration setup uses ElevenLabs-backed voices, treat that as BYOK.
Keep `ELEVENLABS_API_KEY` in your configured `envFile` or project `.env`; see
[Configuration](/docs/reference/configuration). ScreenCI loads that file
automatically for local commands, and ScreenCI does not store the raw
ElevenLabs API key.
Use `voices.elevenlabs({ voiceId })` when you want to target a specific
ElevenLabs voice from your own account:
```ts
import { createNarration, video, voices } from 'screenci'
const narration = createNarration({
en: {
voice: {
name: voices.elevenlabs({ voiceId: 'tMvyQtpCVQ0DkixuYm6J' }),
},
intro: 'Welcome to the dashboard.',
details: 'Open settings to review billing details.',
},
})
video('Billing walkthrough', async ({ page }) => {
await narration.intro()
await narration.details.start()
await page.goto('/settings')
await page.getByRole('button', { name: 'Open billing' }).click()
await narration.details.end()
})
```
Replace the `voiceId` with the voice from your ElevenLabs account. For the
env-file setup, see [Configuration](/docs/reference/configuration).
Author cues sparingly, one sentence at a time, instead of large paragraphs.
That keeps the timeline easier to control and should reduce unnecessary
synthesis/API cost when you revise only part of the script.
+6
View File
@@ -207,6 +207,7 @@ Common options:
- per-language `voice` overrides when one language needs a different voice
- cue entries as text or file-based entries, depending on how you want to
source narration
- short, sentence-sized cues instead of paragraph-sized narration blocks
```ts
const narration = createNarration({
@@ -233,5 +234,10 @@ video('Billing walkthrough', async ({ page }) => {
})
```
Prefer one sentence per cue. Split longer narration into separate named cues and
place them where they belong in the flow. That gives you cleaner overlap
control, makes revisions less brittle, and should save API cost when a TTS
provider such as ElevenLabs only needs to regenerate one changed sentence.
API reference: [createNarration()](/docs/reference/api/functions/createnarration),
[voices](/docs/reference/api/variables/voices)
+33
View File
@@ -229,6 +229,38 @@ describe('createNarration', () => {
)
})
it('throws during createNarration when an ElevenLabs voice is configured without ELEVENLABS_API_KEY', () => {
delete process.env.ELEVENLABS_API_KEY
expect(() =>
createNarration({
voice: {
name: voices.elevenlabs({ voiceId: 'tMvyQtpCVQ0DkixuYm6J' }),
},
en: {
intro: 'Hello world',
},
})
).toThrow(
'createNarration(en) uses an ElevenLabs voice, but ELEVENLABS_API_KEY is not set. Add ELEVENLABS_API_KEY to your env file or process environment. See https://screenci.com/docs/narration-and-localization.'
)
})
it('throws during createNarration when a custom voice ref is configured without ELEVENLABS_API_KEY', () => {
delete process.env.ELEVENLABS_API_KEY
expect(() =>
createNarration({
voice: { name: { path: './voice.mp3' } },
en: {
intro: 'Hello world',
},
})
).toThrow(
'createNarration(en) uses an ElevenLabs voice, but ELEVENLABS_API_KEY is not set. Add ELEVENLABS_API_KEY to your env file or process environment. See https://screenci.com/docs/narration-and-localization.'
)
})
it('throws when a video cue name is reused in one recording', async () => {
const first = createNarration({
voice: { name: voices.Ava },
@@ -648,6 +680,7 @@ describe('createNarration', () => {
})
it('allows custom voice refs before validation and resolves them at start', async () => {
process.env.ELEVENLABS_API_KEY = 'test-elevenlabs-key'
const tempDir = mkdtempSync(join(tmpdir(), 'screenci-voice-'))
try {
+24
View File
@@ -30,6 +30,8 @@ import { resolveRecordingTimingDuration } from './runtimeMode.js'
// One frame at 24fps — ensures at least one rendered frame captures each cue state.
export const ONE_FRAME_MS = 1000 / 24
const ELEVENLABS_DOCS_URL =
'https://screenci.com/docs/narration-and-localization'
// Blocking sleep — spin until the elapsed time has passed
let sleepFn = (ms: number): void => {
@@ -107,6 +109,23 @@ async function toRecordedVoice(
}
}
function usesElevenLabsVoice(voice: VoiceKey | CustomVoiceRef): boolean {
if (isCustomVoiceRef(voice)) return true
return voice.startsWith('elevenlabs:')
}
function assertElevenLabsApiKeyConfigured(
voice: VoiceKey | CustomVoiceRef,
location: string
): void {
if (!usesElevenLabsVoice(voice)) return
if (process.env.ELEVENLABS_API_KEY?.trim()) return
throw new Error(
`${location} uses an ElevenLabs voice, but ELEVENLABS_API_KEY is not set. Add ELEVENLABS_API_KEY to your env file or process environment. See ${ELEVENLABS_DOCS_URL}.`
)
}
/**
* Auto-ends any currently active cue before starting a new one.
* Called internally at the start of every narration controller.
@@ -532,6 +551,11 @@ function buildCuesFromInput(
? 'expressive'
: (langOverride?.modelType ?? topVoice.modelType)
assertElevenLabsApiKeyConfigured(
effectiveVoiceName,
`createNarration(${lang})`
)
resolvedVoices.set(lang, effectiveVoiceName)
resolvedVoiceMeta.set(lang, {
name: voiceToKeyString(effectiveVoiceName),
+5
View File
@@ -14,6 +14,7 @@ import type {
import { RENDER_OPTIONS_DEFAULTS } from './types.js'
import type { VoiceKey } from './voices.js'
import { DEFAULT_ZOOM_OPTIONS } from './defaults.js'
import { getGitMetadata } from './git.js'
function assertAutoZoomUnitIntervalOption(
value: number,
@@ -859,6 +860,8 @@ export class EventRecorder implements IEventRecorder {
}
const languages = languageSet.size > 0 ? [...languageSet].sort() : undefined
const git = getGitMetadata()
const data: RecordingData = {
events: this.events,
renderOptions: resolved,
@@ -870,6 +873,8 @@ export class EventRecorder implements IEventRecorder {
screenciVersion: SCREENCI_VERSION,
...(languages !== undefined && { languages }),
...(sourceFilePath !== undefined && { sourceFilePath }),
...(git.commit !== undefined && { commit: git.commit }),
...(git.isDirty !== undefined && { isDirty: git.isDirty }),
},
}
await writeFile(filePath, JSON.stringify(data, null, 2))
+91
View File
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mockExecFileSync = vi.fn<(cmd: string, args: string[]) => string>()
vi.mock('node:child_process', () => ({
execFileSync: (cmd: string, args: string[]) => mockExecFileSync(cmd, args),
}))
// Import after the mock is registered.
const { getGitMetadata } = await import('./git.js')
const CI_VARS = [
'CI',
'CONTINUOUS_INTEGRATION',
'GITHUB_ACTIONS',
'GITLAB_CI',
'BUILDKITE',
'CIRCLECI',
]
const savedEnv: Record<string, string | undefined> = {}
beforeEach(() => {
vi.resetAllMocks()
for (const key of CI_VARS) {
savedEnv[key] = process.env[key]
delete process.env[key]
}
})
afterEach(() => {
for (const key of CI_VARS) {
if (savedEnv[key] === undefined) delete process.env[key]
else process.env[key] = savedEnv[key]
}
})
describe('getGitMetadata', () => {
it('returns the 8-char commit and isDirty=true for a dirty repo', () => {
mockExecFileSync.mockImplementation((_cmd, args) => {
if (args[0] === 'rev-parse') return 'abcd1234ef567890\n'
if (args[0] === 'status') return ' M src/foo.ts\n'
return ''
})
expect(getGitMetadata()).toEqual({ commit: 'abcd1234', isDirty: true })
})
it('returns isDirty=false for a clean repo', () => {
mockExecFileSync.mockImplementation((_cmd, args) => {
if (args[0] === 'rev-parse') return 'abcd1234ef567890\n'
if (args[0] === 'status') return ''
return ''
})
expect(getGitMetadata()).toEqual({ commit: 'abcd1234', isDirty: false })
})
it('reports isDirty=false in CI even when the working tree is dirty', () => {
process.env.CI = 'true'
mockExecFileSync.mockImplementation((_cmd, args) => {
if (args[0] === 'rev-parse') return 'abcd1234ef567890\n'
if (args[0] === 'status') return ' M src/foo.ts\n'
return ''
})
const result = getGitMetadata()
expect(result).toEqual({ commit: 'abcd1234', isDirty: false })
// status must not even be consulted in CI
expect(
mockExecFileSync.mock.calls.some((call) => call[1][0] === 'status')
).toBe(false)
})
it('returns an empty object when git is unavailable / not a repo', () => {
mockExecFileSync.mockImplementation(() => {
throw new Error('not a git repository')
})
expect(getGitMetadata()).toEqual({})
})
it('still returns the commit when the dirty check fails', () => {
mockExecFileSync.mockImplementation((_cmd, args) => {
if (args[0] === 'rev-parse') return 'abcd1234ef567890\n'
throw new Error('status failed')
})
expect(getGitMetadata()).toEqual({ commit: 'abcd1234' })
})
})
+55
View File
@@ -0,0 +1,55 @@
import { execFileSync } from 'node:child_process'
export type GitMetadata = {
/** First 8 characters of the current commit hash, when in a git repo. */
commit?: string
/**
* True when the working tree has uncommitted changes. Always false in CI
* (CI checkouts are treated as clean). Omitted when it cannot be determined.
*/
isDirty?: boolean
}
/** Treat common CI environments as always-clean. */
function isCI(): boolean {
const env = process.env
return Boolean(
env.CI ||
env.CONTINUOUS_INTEGRATION ||
env.GITHUB_ACTIONS ||
env.GITLAB_CI ||
env.BUILDKITE ||
env.CIRCLECI
)
}
function runGit(args: string[]): string {
return execFileSync('git', args, {
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8',
}).trim()
}
/**
* Best-effort git metadata for the recording. Never throws returns an empty
* object when git is unavailable or the directory is not a repository.
*/
export function getGitMetadata(): GitMetadata {
try {
const commit = runGit(['rev-parse', 'HEAD']).slice(0, 8)
if (commit.length === 0) return {}
// In CI the checkout is considered clean regardless of working tree state.
if (isCI()) return { commit, isDirty: false }
try {
const status = runGit(['status', '--porcelain'])
return { commit, isDirty: status.length > 0 }
} catch {
// Could not determine dirtiness; still report the commit.
return { commit }
}
} catch {
return {}
}
}
+4
View File
@@ -266,6 +266,10 @@ export type RecordingMetadata = {
screenciVersion: string
languages?: string[]
sourceFilePath?: string
/** First 8 chars of the git commit the recording was made at, if available. */
commit?: string
/** Whether the repo had uncommitted changes (always false in CI). */
isDirty?: boolean
}
export type RecordingData = {