feat: reuse uploaded asset media

This commit is contained in:
Olli Paloviita
2026-06-29 16:17:56 +03:00
parent c8634e40b4
commit 6bf8ef1051
25 changed files with 1326 additions and 333 deletions
+15
View File
@@ -20,6 +20,7 @@ const mockStat = vi.fn()
const mockCreateReadStream = vi.fn()
const mockAppendFile = vi.fn()
const mockWriteFile = vi.fn()
const mockCopyFile = vi.fn()
const mockMkdir = vi.fn()
const mockInput = vi.fn()
const mockConfirm = vi.fn()
@@ -214,6 +215,7 @@ vi.mock('fs', () => ({
vi.mock('fs/promises', () => ({
appendFile: mockAppendFile,
copyFile: mockCopyFile,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
@@ -221,6 +223,7 @@ vi.mock('fs/promises', () => ({
mkdir: mockMkdir,
default: {
appendFile: mockAppendFile,
copyFile: mockCopyFile,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
@@ -262,6 +265,7 @@ describe('CLI', () => {
mockSpawn.mockReset()
mockAppendFile.mockResolvedValue(undefined)
mockWriteFile.mockResolvedValue(undefined)
mockCopyFile.mockResolvedValue(undefined)
mockMkdir.mockResolvedValue(undefined)
mockReaddir.mockResolvedValue([])
mockReadFileSync.mockImplementation(() => {
@@ -422,6 +426,17 @@ describe('CLI', () => {
recursive: true,
}
)
// The brand intro logo is copied into the (gitignored) assets folder.
expect(mockMkdir).toHaveBeenCalledWith(
'/workspace/my-app/screenci/recordings/assets',
{
recursive: true,
}
)
expect(mockCopyFile).toHaveBeenCalledWith(
expect.stringContaining('logo.png'),
'/workspace/my-app/screenci/recordings/assets/logo.png'
)
expect(mockWriteFile).toHaveBeenCalledWith(
'/workspace/my-app/screenci/screenci.config.ts',
expect.stringContaining('"My Project"')
+556 -238
View File
@@ -39,6 +39,7 @@ import {
SCREENCI_LANGUAGES_ENV,
SCREENCI_VALUES_OVERRIDES_ENV,
SCREENCI_RECORD_OPTIONS_ENV,
isUploadExistingEnabled,
} from './src/runtimeMode.js'
import { DEFAULT_RECORD_UPLOAD_POLICY } from './src/defaults.js'
import type { VoiceKey } from './src/voices.js'
@@ -389,6 +390,16 @@ type PreparedUploadAsset = {
// Used for per-recording ephemeral captures (screen audio) that are never
// shared across recordings and must not be silently skipped.
alwaysUpload?: boolean
// The local file was absent when assets were collected, so its bytes (and, for
// overlays, its content hash) are not available. The asset's identity must be
// recovered from a previous upload of this video (matched by path/name) before
// the recording is started. Once recovered, fileHash/size/contentType are
// filled in and `assumedUploaded` is set. See resolveMissingUploadAssets.
needsResolve?: boolean
// Set once a missing local file has been matched to a previously uploaded
// asset. The asset is referenced by its known hash with no local bytes; the
// backend existence check confirms it is still stored.
assumedUploaded?: boolean
}
type UploadCandidate = {
@@ -592,10 +603,14 @@ async function loadUploadCandidate(
resolve(screenciDir, '..')
)
// The recording data is annotated with asset hashes later, in
// uploadRecordingCandidate, after any locally missing assets have been
// resolved against a previous upload. Annotating here would strip the asset
// paths the resolve step needs to match on.
return {
entry,
videoName,
data: annotateRecordingDataWithAssetHashes(data, preparedUploadAssets),
data,
preparedUploadAssets,
}
}
@@ -617,7 +632,7 @@ async function uploadRecordingCandidate(
recordId: string,
expectedScreenshotCount: number
): Promise<UploadJobResult> {
const { entry, videoName, data, preparedUploadAssets } = candidate
const { entry, videoName, data: rawData, preparedUploadAssets } = candidate
let projectId: string | null = null
let videoId: string | null = null
let plan: OrgPlan | null = null
@@ -627,9 +642,9 @@ async function uploadRecordingCandidate(
// A screenshot recording uploads its raw page capture (always a PNG) through
// the same recording endpoint a video uses; the renderer reads those bytes as
// the capture. Videos upload recording.mp4. Output kind defaults to 'video'.
const isScreenshot = data.output === 'screenshot'
const isScreenshot = rawData.output === 'screenshot'
const recordingFileName = isScreenshot
? (data.screenshot?.path ?? 'screenshot.png')
? (rawData.screenshot?.path ?? 'screenshot.png')
: 'recording.mp4'
const recordingContentType = isScreenshot ? 'image/png' : 'video/mp4'
const recordingPath = resolve(screenciDir, entry, recordingFileName)
@@ -645,6 +660,36 @@ async function uploadRecordingCandidate(
}
}
// Locally missing assets (e.g. gitignored media on CI) carry no bytes and,
// for overlays, no hash. Recover their identity from a previous upload of
// this video before starting, so the recording data references them by hash
// and the backend existence check confirms they are still stored.
const unresolved = await resolveMissingUploadAssets(
preparedUploadAssets,
projectName,
videoName,
apiUrl,
secret,
uploadAbort.signal,
progressReporter
)
if (unresolved.length > 0) {
progressReporter.complete(progressIndex, 'failure')
return {
projectId: null,
videoId: null,
hadFailure: true,
videoName,
failureMessage: formatUnresolvedAssetMessage(videoName, unresolved),
recordId,
}
}
const data = annotateRecordingDataWithAssetHashes(
rawData,
preparedUploadAssets
)
const recordingHash = await hashFile(recordingPath)
const startResponse = await withUploadRetry(
() =>
@@ -1235,23 +1280,32 @@ async function prepareCustomVoiceAssets(
const existingHash = refs.find(
(ref) => typeof ref.assetHash === 'string'
)?.assetHash
if (!existingHash) {
throw new Error(
`Custom voice file not found and no cached assetHash available: ${voicePath}`
)
}
logger.warn(
`Custom voice file not found locally, assuming previously uploaded recording asset is valid: ${voicePath}`
)
for (const ref of refs) {
ref.assetHash = existingHash
if (existingHash) {
// The recording already carries this voice's content hash, so reference
// it by that hash. The backend check confirms it is still stored.
for (const ref of refs) {
ref.assetHash = existingHash
}
preparedAssets.push({
kind: 'voice',
fileHash: existingHash,
path: voicePath,
size: 0,
contentType: contentTypeForPath(voicePath),
assumedUploaded: true,
})
continue
}
// No cached hash either. Recover the voice's identity from a previous
// upload of this video, matched by path. resolveMissingUploadAssets fills
// in the hash and writes it back onto the cue refs.
preparedAssets.push({
kind: 'voice',
fileHash: existingHash,
fileHash: '',
path: voicePath,
size: 0,
contentType: contentTypeForPath(voicePath),
needsResolve: true,
})
continue
}
@@ -1310,7 +1364,16 @@ export async function collectUploadAssets(
sourceFilePath
)
if (resolvedFile === null) {
logger.warn(`Overlay file not found, skipping upload: ${event.path}`)
// The local file is gone (e.g. gitignored media on CI). Reference it so
// its identity can be recovered from a previous upload of this video.
assets.set(`name:${event.name}`, {
kind: 'overlay',
fileHash: '',
path: event.path,
name: event.name,
size: 0,
needsResolve: true,
})
continue
}
const { buffer: fileBuffer, resolvedPath } = resolvedFile
@@ -1329,19 +1392,48 @@ export async function collectUploadAssets(
if (event.type === 'audioStart') {
// Studio audio tracks have no local file.
if ('studio' in event && event.studio === true) continue
if (!event.fileHash || assets.has(`hash:${event.fileHash}`)) continue
// Prefer the record-time content hash as the dedup key; a missing local
// file may have been emitted without one, so fall back to the path.
const dedupKey = event.fileHash
? `hash:${event.fileHash}`
: `path:${event.path}`
if (assets.has(dedupKey)) continue
const resolvedFile = await readRecordingFile(
event.path,
configDir,
sourceFilePath
)
if (resolvedFile === null) {
logger.warn(`Audio file not found, skipping upload: ${event.path}`)
// The local file is gone. If the recording still carries its content
// hash, reference it by that hash (the backend check confirms it is
// stored). Otherwise recover its identity from a previous upload by path.
// Captured screen audio (`__screen`) is per-recording and can never be
// recovered from a prior upload, so it is simply skipped when missing.
if (event.fileHash) {
assets.set(dedupKey, {
kind: 'audio',
fileHash: event.fileHash,
path: event.path,
size: 0,
assumedUploaded: true,
...(event.name === '__screen' && { alwaysUpload: true }),
})
} else if (event.name !== '__screen') {
assets.set(dedupKey, {
kind: 'audio',
fileHash: '',
path: event.path,
size: 0,
needsResolve: true,
})
}
continue
}
assets.set(`hash:${event.fileHash}`, {
assets.set(dedupKey, {
kind: 'audio',
fileHash: event.fileHash,
fileHash:
event.fileHash ??
createHash('sha256').update(resolvedFile.buffer).digest('hex'),
path: event.path,
size: resolvedFile.buffer.byteLength,
fileBuffer: resolvedFile.buffer,
@@ -1370,44 +1462,86 @@ export async function collectUploadAssets(
fileHash: event.assetHash,
path: event.assetPath ?? event.assetHash,
size: resolvedFile?.buffer.byteLength ?? 0,
...(resolvedFile !== null && {
fileBuffer: resolvedFile.buffer,
contentType: contentTypeForPath(resolvedFile.resolvedPath),
}),
...(resolvedFile !== null
? {
fileBuffer: resolvedFile.buffer,
contentType: contentTypeForPath(resolvedFile.resolvedPath),
}
: { assumedUploaded: true }),
})
} else if (
typeof event.assetHash !== 'string' &&
typeof event.assetPath === 'string' &&
!assets.has(`path:${event.assetPath}`)
) {
// The media file was gone at record time, so it carries no hash. Recover
// its identity from a previous upload of this video, matched by path.
const resolvedFile = await readRecordingFile(
event.assetPath,
configDir,
sourceFilePath
)
if (resolvedFile === null) {
assets.set(`path:${event.assetPath}`, {
kind: 'clip',
fileHash: '',
path: event.assetPath,
size: 0,
needsResolve: true,
})
}
}
// Multi-language: each translation carries its own hash
if (event.translations) {
for (const translation of Object.values(event.translations)) {
if (
typeof translation === 'object' &&
translation !== null &&
if (typeof translation !== 'object' || translation === null) continue
const assetHash =
'assetHash' in translation &&
typeof translation.assetHash === 'string' &&
!assets.has(`hash:${translation.assetHash}`)
) {
typeof translation.assetHash === 'string'
? translation.assetHash
: undefined
const assetPath =
'assetPath' in translation &&
typeof translation.assetPath === 'string'
? translation.assetPath
: undefined
if (assetHash !== undefined) {
if (assets.has(`hash:${assetHash}`)) continue
const resolvedFile =
'assetPath' in translation &&
typeof translation.assetPath === 'string'
? await readRecordingFile(
translation.assetPath,
configDir,
sourceFilePath
)
assetPath !== undefined
? await readRecordingFile(assetPath, configDir, sourceFilePath)
: null
assets.set(`hash:${translation.assetHash}`, {
assets.set(`hash:${assetHash}`, {
kind: 'clip',
fileHash: translation.assetHash,
path:
(translation as { assetPath?: string }).assetPath ??
translation.assetHash,
fileHash: assetHash,
path: assetPath ?? assetHash,
size: resolvedFile?.buffer.byteLength ?? 0,
...(resolvedFile !== null && {
fileBuffer: resolvedFile.buffer,
contentType: contentTypeForPath(resolvedFile.resolvedPath),
}),
...(resolvedFile !== null
? {
fileBuffer: resolvedFile.buffer,
contentType: contentTypeForPath(resolvedFile.resolvedPath),
}
: { assumedUploaded: true }),
})
} else if (
assetPath !== undefined &&
!assets.has(`path:${assetPath}`)
) {
const resolvedFile = await readRecordingFile(
assetPath,
configDir,
sourceFilePath
)
if (resolvedFile === null) {
assets.set(`path:${assetPath}`, {
kind: 'clip',
fileHash: '',
path: assetPath,
size: 0,
needsResolve: true,
})
}
}
}
}
@@ -1422,10 +1556,18 @@ export async function collectUploadAssets(
}
export function stripVoicePath(
voice: VoiceKey | RecordingCustomVoiceRef
voice: VoiceKey | RecordingCustomVoiceRef,
byPath?: Map<string, string>
): VoiceKey | RecordingCustomVoiceRef {
if (typeof voice !== 'string') {
return { assetHash: voice.assetHash }
// A voice recovered from a previous upload has no record-time assetHash; fill
// it in from the resolved-by-path map before the path is dropped.
const assetHash =
voice.assetHash ??
(byPath !== undefined && typeof voice.assetPath === 'string'
? byPath.get(voice.assetPath)
: undefined)
return { assetHash: assetHash as string }
}
return voice
}
@@ -1434,16 +1576,31 @@ export function annotateRecordingDataWithAssetHashes(
data: RecordingData,
assets: PreparedUploadAsset[]
): RecordingData {
// Overlays are matched to their hash by name; every other asset kind (audio,
// narration clip, custom voice) is matched by its file path. Skip placeholder
// hashes that were never resolved so a missing entry stays untouched.
const byName = new Map<string, string>()
const byPath = new Map<string, string>()
for (const asset of assets) {
if (asset.fileHash.length === 0) continue
if (typeof asset.name === 'string') byName.set(asset.name, asset.fileHash)
byPath.set(asset.path, asset.fileHash)
}
return {
...data,
events: data.events.map((event) => {
if (event.type === 'assetStart') {
const fileHash = byName.get(event.name)
if ('studio' in event || 'dependency' in event) return event
const fileHash = byName.get(event.name) ?? event.fileHash
return fileHash ? { ...event, fileHash } : event
}
if (event.type === 'audioStart') {
// Studio audio tracks carry no local path; leave them untouched.
if (!('path' in event)) return event
if (event.fileHash) return event
const fileHash = byPath.get(event.path)
return fileHash ? { ...event, fileHash } : event
}
@@ -1457,7 +1614,7 @@ export function annotateRecordingDataWithAssetHashes(
language,
{
...translation,
voice: stripVoicePath(translation.voice),
voice: stripVoicePath(translation.voice, byPath),
} as typeof translation,
]
})
@@ -1467,14 +1624,23 @@ export function annotateRecordingDataWithAssetHashes(
if (event.type !== 'videoCueStart') return event
// Strip assetPath from translations hash was already computed during recording
// Strip assetPath from translations. The hash was either computed during
// recording or recovered from a previous upload (matched by that path).
if (event.translations) {
const translations = Object.fromEntries(
Object.entries(event.translations).map(([language, translation]) => {
if ('assetHash' in translation) {
const { assetPath: _removed, ...rest } =
translation as VideoCueTranslationFile
return [language, rest]
if ('assetHash' in translation || 'assetPath' in translation) {
const file = translation as VideoCueTranslationFile
const assetHash =
file.assetHash ??
(file.assetPath !== undefined
? byPath.get(file.assetPath)
: undefined)
const { assetPath: _removed, ...rest } = file
return [
language,
assetHash !== undefined ? { ...rest, assetHash } : rest,
]
}
if ('voice' in translation) {
return [
@@ -1482,7 +1648,7 @@ export function annotateRecordingDataWithAssetHashes(
{
...translation,
...(translation.voice !== undefined
? { voice: stripVoicePath(translation.voice) }
? { voice: stripVoicePath(translation.voice, byPath) }
: {}),
},
]
@@ -1493,10 +1659,16 @@ export function annotateRecordingDataWithAssetHashes(
return { ...event, translations }
}
// Single-language: strip assetPath, keep assetHash
if (typeof event.assetHash === 'string') {
// Single-language: keep the assetHash (recovering it by path if needed) and
// drop the now-redundant assetPath.
const assetHash =
event.assetHash ??
(typeof event.assetPath === 'string'
? byPath.get(event.assetPath)
: undefined)
if (typeof assetHash === 'string') {
const { assetPath: _removed, ...rest } = event
return rest
return { ...rest, assetHash }
}
return event
@@ -1578,6 +1750,120 @@ export function displayAssetPath(assetPath: string): string {
return rel.length > 0 ? rel : assetPath
}
type ResolveAssetRef = { path: string; name?: string | null; kind: string }
type ResolveAssetResult = {
path: string
name?: string | null
fileHash: string | null
size: number | null
contentType: string | null
}
/**
* The backend's response to a resolve-assets request. Each entry is aligned to
* the request's `assets` array; a null `fileHash` means no previously uploaded
* version of that asset was found for this video.
*/
export type ResolveAssetsResponse = { resolved: ResolveAssetResult[] }
/**
* Recovers the identity of assets whose local file was absent during collection
* (no bytes, and for overlays no hash) by matching them, by path or name,
* against a previous upload of the same video. Each resolved asset is mutated in
* place: its `fileHash`, `size`, and `contentType` are filled in and it is
* flagged `assumedUploaded` so the later existence check confirms the bytes are
* still stored rather than trying to upload absent bytes.
*
* Returns the assets that could not be resolved (no previous version), so the
* caller can fail the recording with actionable guidance.
*/
export async function resolveMissingUploadAssets(
assets: PreparedUploadAsset[],
projectName: string,
videoName: string,
apiUrl: string,
secret: string,
signal: AbortSignal,
progressReporter?: { info: (message: string) => void }
): Promise<PreparedUploadAsset[]> {
const pending = assets.filter((asset) => asset.needsResolve === true)
if (pending.length === 0) return []
const refs: ResolveAssetRef[] = pending.map((asset) => ({
path: asset.path,
...(typeof asset.name === 'string' ? { name: asset.name } : {}),
kind: asset.kind,
}))
const res = await withUploadRetry(
() =>
fetch(`${apiUrl}/cli/upload/resolve-assets`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-ScreenCI-Secret': secret,
},
body: JSON.stringify({ projectName, videoName, assets: refs }),
signal,
}),
signal
)
if (!res.ok) {
const text = await res.text()
throw new UploadAssetError(
`Failed to resolve previously uploaded assets for "${videoName}": ${res.status} ${text}${hint401(res.status, secret)}`
)
}
const body = (await res.json()) as ResolveAssetsResponse
const resolved = Array.isArray(body.resolved) ? body.resolved : []
const logInfo = (message: string) => {
if (progressReporter) progressReporter.info(message)
else logger.info(message)
}
const unresolved: PreparedUploadAsset[] = []
pending.forEach((asset, index) => {
const match = resolved[index]
if (match && typeof match.fileHash === 'string') {
asset.fileHash = match.fileHash
asset.size = match.size ?? 0
if (typeof match.contentType === 'string') {
asset.contentType = match.contentType
}
asset.needsResolve = false
asset.assumedUploaded = true
logInfo(
`${pc.green('✔')} Locally missing ${UPLOAD_ASSET_LABEL[asset.kind].toLowerCase()}, reusing the previously uploaded version: ${displayAssetPath(asset.path)}`
)
} else {
unresolved.push(asset)
}
})
return unresolved
}
/**
* Builds the failure message shown when locally missing assets have no
* previously uploaded version to reuse.
*/
export function formatUnresolvedAssetMessage(
videoName: string,
unresolved: PreparedUploadAsset[]
): string {
const list = unresolved
.map((asset) => ` - ${UPLOAD_ASSET_LABEL[asset.kind]}: ${asset.path}`)
.join('\n')
return [
`Some asset files are missing locally and no previously uploaded version was found for "${videoName}":`,
list,
'Record once with these files present so they are uploaded, or commit them so they are available here.',
].join('\n')
}
async function uploadAssets(
assets: PreparedUploadAsset[],
apiUrl: string,
@@ -1630,13 +1916,22 @@ async function uploadAssets(
const checkBody = (await checkRes.json()) as { exists: boolean }
if (checkBody.exists) {
logInfo(
`${pc.green('✔')} ${label} already exists: ${displayAssetPath(asset.path)}`
asset.assumedUploaded
? `${pc.green('✔')} Locally missing ${label.toLowerCase()}, already uploaded: ${displayAssetPath(asset.path)}`
: `${pc.green('✔')} ${label} already exists: ${displayAssetPath(asset.path)}`
)
continue
}
}
if (!asset.fileBuffer || !asset.contentType) {
// A locally missing asset matched a previous upload by path/name, but its
// bytes are not in this environment's storage. There is nothing to push.
if (asset.assumedUploaded) {
throw new UploadAssetError(
`${label} is missing locally and its previously uploaded bytes are no longer stored: ${displayAssetPath(asset.path)}. Record once with the file present so it is uploaded again, or commit the file.`
)
}
throw new UploadAssetError(
`Asset bytes not available for upload and backend does not have it yet: ${displayAssetPath(asset.path)}`
)
@@ -2701,6 +2996,173 @@ export async function runLogin(configPath?: string): Promise<void> {
}
}
// Uploads the recordings already written under `.screenci` for the resolved
// config. Shared by `record` (after a Playwright run) and `retry` (which
// re-sends the existing recordings without re-running Playwright). A non-null
// `playwrightFailure` means the preceding record run had failures, which tunes
// the messaging and the upload policy; `retry` always passes null.
async function uploadRecordedVideosForConfig(
configPath: string | undefined,
playwrightFailure: Error | null,
verbose: boolean
): Promise<void> {
// After recording, upload results to API if configured. `run` already
// resolved the config (or exited), so this best-effort lookup only acts
// when a flat config is present in/under the current directory.
const resolution = findScreenCIConfig(configPath)
if (resolution.kind !== 'found') return
const resolvedConfigPath = resolution.path
try {
const screenciConfig =
await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath)
loadEnvFile(
screenciConfig.envFile
? resolve(dirname(resolvedConfigPath), screenciConfig.envFile)
: resolve(dirname(resolvedConfigPath), '.env'),
true
)
const apiUrl = getDevBackendUrl()
const appUrl = getDevFrontendUrl()
const secret = process.env.SCREENCI_SECRET
const uploadPolicy = resolveRecordUploadPolicy(screenciConfig)
const configDir = dirname(resolvedConfigPath)
const screenciDir = resolve(configDir, '.screenci')
const completedRecordingCount = await countCompletedRecordings(screenciDir)
if (playwrightFailure !== null && completedRecordingCount === 0) {
logger.info('All recordings failed.')
} else if (!secret) {
logger.info(
`No SCREENCI_SECRET configured for uploads. Rerun ${getSuggestedScreenciCommand('record')} or add it to the project env file.`
)
} else if (
playwrightFailure !== null &&
uploadPolicy === 'all-or-nothing'
) {
logger.info(
'Some recordings failed, skipping upload because record.upload is "all-or-nothing".'
)
} else {
if (playwrightFailure !== null && uploadPolicy === 'passed-only') {
logger.warn('Some recordings failed, uploading successful videos only.')
}
let uploadResult: {
projectId: string | null
recordId: string | null
hadFailures: boolean
failedVideoNames: string[]
failedVideoMessages: Array<{ videoName: string; message: string }>
studioNotices: StudioUploadNotice[]
plan: OrgPlan | null
} = {
projectId: null,
recordId: null,
hadFailures: false,
failedVideoNames: [],
failedVideoMessages: [],
studioNotices: [],
plan: null,
}
try {
uploadResult = await uploadRecordings(
screenciDir,
screenciConfig.projectName,
apiUrl,
secret,
undefined,
verbose
)
} catch (err) {
if (isUploadCancelledError(err)) {
process.exit(130)
}
throw err
}
const {
projectId,
recordId,
hadFailures,
failedVideoNames,
failedVideoMessages,
studioNotices,
plan,
} = uploadResult
// Remember this run so `screenci info` can report exactly it.
if (recordId !== null) {
await saveLastRecordId(screenciDir, recordId)
}
// Emit upload-failure warnings (stderr) before the results block.
// logger.info writes to stdout, logger.warn to stderr; in non-TTY CI
// logs stdout is block-buffered while stderr flushes immediately, so
// warnings printed after the "Results available at:" line would split
// 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)
)
}
logger.warn(
`Not all recordings succeeded to upload. Failed videos: ${failedVideoNames.join(', ') || 'unknown'}. Some videos may be missing from the project.`
)
}
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('')
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(projectUrl))
}
if (projectId !== null && plan !== 'business') {
logger.info('')
logger.info(
'Upgrade for more renders, more active videos, and expressive narration:'
)
logger.info(pc.cyan(`${appUrl}/select-plan`))
}
for (const notice of studioNotices) {
if ('held' in notice.studio) {
logger.info('')
logger.info(
`Rendering for "${notice.videoName}" is on hold. Configure it in Studio:`
)
if (projectId !== null && notice.videoId !== null) {
logger.info(
pc.cyan(formatStudioUrl(appUrl, projectId, notice.videoId))
)
}
} else if (notice.studio.applied) {
logger.info('')
logger.info(`Studio configuration applied for "${notice.videoName}".`)
}
}
if (hadFailures && playwrightFailure === null) {
throw new PartialUploadError()
}
}
} catch (err) {
if (isPartialUploadError(err)) {
throw err
}
logger.warn('Failed to load config for upload:', err)
}
}
export async function main() {
if (process.argv.length <= 2) {
logger.error('Error: No command provided')
@@ -2755,189 +3217,45 @@ export async function main() {
let playwrightFailure: Error | null = null
try {
await run(
'record',
parsed.otherArgs,
parsed.configPath,
parsed.verbose,
false,
parsed.pollAuth,
parsed.languages,
parsed.noPollAuth
)
} 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)
// 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.pollAuth,
parsed.languages,
parsed.noPollAuth
)
} 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
// After recording, upload results to API if configured. `run` already
// resolved the config (or exited), so this best-effort lookup only acts
// when a flat config is present in/under the current directory.
const resolution = findScreenCIConfig(parsed.configPath)
if (resolution.kind === 'found') {
const resolvedConfigPath = resolution.path
try {
const screenciConfig =
await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath)
loadEnvFile(
screenciConfig.envFile
? resolve(dirname(resolvedConfigPath), screenciConfig.envFile)
: resolve(dirname(resolvedConfigPath), '.env'),
true
)
const apiUrl = getDevBackendUrl()
const appUrl = getDevFrontendUrl()
const secret = process.env.SCREENCI_SECRET
const uploadPolicy = resolveRecordUploadPolicy(screenciConfig)
const configDir = dirname(resolvedConfigPath)
const screenciDir = resolve(configDir, '.screenci')
const completedRecordingCount =
await countCompletedRecordings(screenciDir)
if (playwrightFailure !== null && completedRecordingCount === 0) {
logger.info('All recordings failed.')
} else if (!secret) {
logger.info(
`No SCREENCI_SECRET configured for uploads. Rerun ${getSuggestedScreenciCommand('record')} or add it to the project env file.`
)
} else if (
playwrightFailure !== null &&
uploadPolicy === 'all-or-nothing'
) {
logger.info(
'Some recordings failed, skipping upload because record.upload is "all-or-nothing".'
)
} else {
if (playwrightFailure !== null && uploadPolicy === 'passed-only') {
logger.warn(
'Some recordings failed, uploading successful videos only.'
)
}
let uploadResult: {
projectId: string | null
recordId: string | null
hadFailures: boolean
failedVideoNames: string[]
failedVideoMessages: Array<{ videoName: string; message: string }>
studioNotices: StudioUploadNotice[]
plan: OrgPlan | null
} = {
projectId: null,
recordId: null,
hadFailures: false,
failedVideoNames: [],
failedVideoMessages: [],
studioNotices: [],
plan: null,
}
try {
uploadResult = await uploadRecordings(
screenciDir,
screenciConfig.projectName,
apiUrl,
secret
)
} catch (err) {
if (isUploadCancelledError(err)) {
process.exit(130)
}
throw err
}
const {
projectId,
recordId,
hadFailures,
failedVideoNames,
failedVideoMessages,
studioNotices,
plan,
} = uploadResult
// Remember this run so `screenci info` can report exactly it.
if (recordId !== null) {
await saveLastRecordId(screenciDir, recordId)
}
// Emit upload-failure warnings (stderr) before the results block.
// logger.info writes to stdout, logger.warn to stderr; in non-TTY CI
// logs stdout is block-buffered while stderr flushes immediately, so
// warnings printed after the "Results available at:" line would split
// 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
)
)
}
logger.warn(
`Not all recordings succeeded to upload. Failed videos: ${failedVideoNames.join(', ') || 'unknown'}. Some videos may be missing from the project.`
)
}
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('')
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(projectUrl))
}
if (projectId !== null && plan !== 'business') {
logger.info('')
logger.info(
'Upgrade for more renders, more active videos, and expressive narration:'
)
logger.info(pc.cyan(`${appUrl}/select-plan`))
}
for (const notice of studioNotices) {
if ('held' in notice.studio) {
logger.info('')
logger.info(
`Rendering for "${notice.videoName}" is on hold — configure it in Studio:`
)
if (projectId !== null && notice.videoId !== null) {
logger.info(
pc.cyan(formatStudioUrl(appUrl, projectId, notice.videoId))
)
}
} else if (notice.studio.applied) {
logger.info('')
logger.info(
`Studio configuration applied for "${notice.videoName}".`
)
}
}
if (hadFailures && playwrightFailure === null) {
throw new PartialUploadError()
}
}
} catch (err) {
if (isPartialUploadError(err)) {
throw err
}
logger.warn('Failed to load config for upload:', err)
}
}
await uploadRecordedVideosForConfig(
parsed.configPath,
playwrightFailure,
parsed.verbose
)
if (playwrightFailure !== null) {
throw playwrightFailure
+279
View File
@@ -503,6 +503,285 @@ describe('CLI', () => {
})
})
describe('locally missing assets', () => {
const HASH_A = 'a'.repeat(64)
const HASH_B = 'b'.repeat(64)
const HASH_C = 'c'.repeat(64)
const HASH_D = 'd'.repeat(64)
it('marks a locally missing overlay for resolution from a previous upload', async () => {
const { collectUploadAssets } = await import('./cli')
mockReadFile.mockRejectedValue(new Error('ENOENT'))
const assets = await collectUploadAssets(
{
events: [
{
type: 'assetStart',
timeMs: 0,
name: 'logo',
kind: 'image',
path: './assets/logo.png',
fullScreen: false,
},
],
} as unknown as RecordingData,
'/project'
)
expect(assets).toEqual([
{
kind: 'overlay',
fileHash: '',
path: './assets/logo.png',
name: 'logo',
size: 0,
needsResolve: true,
},
])
})
it('treats a missing audio track with a known hash as already uploaded', async () => {
const { collectUploadAssets } = await import('./cli')
mockReadFile.mockRejectedValue(new Error('ENOENT'))
const assets = await collectUploadAssets(
{
events: [
{
type: 'audioStart',
timeMs: 0,
name: 'music',
path: './assets/music.mp3',
fileHash: HASH_A,
volume: 1,
repeat: false,
},
],
} as unknown as RecordingData,
'/project'
)
expect(assets).toEqual([
{
kind: 'audio',
fileHash: HASH_A,
path: './assets/music.mp3',
size: 0,
assumedUploaded: true,
},
])
})
it('marks a missing audio track without a hash for resolution', async () => {
const { collectUploadAssets } = await import('./cli')
mockReadFile.mockRejectedValue(new Error('ENOENT'))
const assets = await collectUploadAssets(
{
events: [
{
type: 'audioStart',
timeMs: 0,
name: 'music',
path: './assets/music.mp3',
volume: 1,
repeat: false,
},
],
} as unknown as RecordingData,
'/project'
)
expect(assets).toEqual([
{
kind: 'audio',
fileHash: '',
path: './assets/music.mp3',
size: 0,
needsResolve: true,
},
])
})
it('fills a resolved audio hash by path when annotating', async () => {
const { annotateRecordingDataWithAssetHashes } = await import('./cli')
const result = annotateRecordingDataWithAssetHashes(
{
events: [
{
type: 'audioStart',
timeMs: 0,
name: 'music',
path: './assets/music.mp3',
volume: 1,
repeat: false,
},
],
} as unknown as RecordingData,
[
{
kind: 'audio',
fileHash: HASH_B,
path: './assets/music.mp3',
size: 10,
assumedUploaded: true,
},
]
)
expect(result.events[0]).toMatchObject({
type: 'audioStart',
fileHash: HASH_B,
})
})
it('fills a resolved narration hash by path and strips the path', async () => {
const { annotateRecordingDataWithAssetHashes } = await import('./cli')
const result = annotateRecordingDataWithAssetHashes(
{
events: [
{
type: 'videoCueStart',
timeMs: 0,
name: 'intro',
translations: {
en: { assetPath: './assets/clip.mp4', subtitle: 'Hi' },
},
},
],
} as unknown as RecordingData,
[
{
kind: 'clip',
fileHash: HASH_C,
path: './assets/clip.mp4',
size: 5,
assumedUploaded: true,
},
]
)
const event = result.events[0] as {
translations: Record<string, unknown>
}
expect(event.translations.en).toEqual({
assetHash: HASH_C,
subtitle: 'Hi',
})
})
it('resolves missing assets against a previous upload and reports the rest', async () => {
const { resolveMissingUploadAssets } = await import('./cli')
const assets = [
{
kind: 'overlay' as const,
fileHash: '',
path: './assets/logo.png',
name: 'logo',
size: 0,
needsResolve: true,
},
{
kind: 'audio' as const,
fileHash: '',
path: './assets/music.mp3',
size: 0,
needsResolve: true,
},
]
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({
resolved: [
{
path: './assets/logo.png',
name: 'logo',
fileHash: HASH_D,
size: 11,
contentType: 'image/png',
},
{
path: './assets/music.mp3',
name: null,
fileHash: null,
size: null,
contentType: null,
},
],
}),
text: vi.fn().mockResolvedValue(''),
})
const unresolved = await resolveMissingUploadAssets(
assets,
'My Project',
'My Video',
'https://api.example.com',
'secret',
new AbortController().signal
)
expect(assets[0]).toEqual({
kind: 'overlay',
fileHash: HASH_D,
path: './assets/logo.png',
name: 'logo',
size: 11,
contentType: 'image/png',
needsResolve: false,
assumedUploaded: true,
})
expect(unresolved).toEqual([assets[1]])
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/cli/upload/resolve-assets',
expect.objectContaining({ method: 'POST' })
)
})
it('skips the resolve request when nothing is missing', async () => {
const { resolveMissingUploadAssets } = await import('./cli')
const unresolved = await resolveMissingUploadAssets(
[
{
kind: 'overlay',
fileHash: HASH_A,
path: './assets/logo.png',
name: 'logo',
size: 10,
},
],
'My Project',
'My Video',
'https://api.example.com',
'secret',
new AbortController().signal
)
expect(unresolved).toEqual([])
expect(mockFetch).not.toHaveBeenCalled()
})
it('formats an actionable message for unresolved assets', async () => {
const { formatUnresolvedAssetMessage } = await import('./cli')
const message = formatUnresolvedAssetMessage('My Video', [
{
kind: 'overlay',
fileHash: '',
path: './assets/logo.png',
name: 'logo',
size: 0,
},
])
expect(message).toContain('missing locally and no previously uploaded')
expect(message).toContain('Overlay: ./assets/logo.png')
expect(message).toContain('Record once with these files present')
})
})
describe('withUploadRetry', () => {
it('returns the result when the first attempt succeeds', async () => {
const { withUploadRetry } = await import('./cli')
+6
View File
@@ -53,3 +53,9 @@ Studio page instead of keeping them in the repository. See
For per-language audio tracks (e.g. a locale-specific music bed), see
[Languages](/docs/guides/languages).
Audio files are uploaded the first time you record with them present and reused
on later runs, so you do not have to commit the files. If a file is missing
locally, ScreenCI reuses the version uploaded for this video (matched by file
path). See
[Asset files do not need to be committed](/docs/ci-setup#asset-files-do-not-need-to-be-committed).
+37
View File
@@ -9,6 +9,7 @@ deterministic CI environment.
- [what the generated workflow does](#generated-workflow)
- [which secret is required](#required-secret)
- [how to keep CI recordings predictable](#keep-recordings-deterministic)
- [why asset files do not need to be committed](#asset-files-do-not-need-to-be-committed)
## Generated workflow
@@ -117,6 +118,42 @@ For faster, smoother recordings:
},
```
## Asset files do not need to be committed
Overlay images and videos, background audio, and narration media (the files you
reference with `video.overlays(...)`, `video.audio(...)`, and narration `media`
cues) are uploaded to ScreenCI the first time you record with the files present. On
later runs they are reused: ScreenCI matches each asset to the version uploaded
for the same video (by file path, or by overlay name) and reuses it.
That means you do not have to commit these (often large) media files to the
repository. The `screenci init` scaffold gitignores the `recordings/assets/`
folder for exactly this reason. A typical flow:
1. Record locally once with the asset files present. The recording uploads them.
2. Keep the files out of git (or delete them). The committed `.screenci.ts`
scripts still reference them by path.
3. On CI, the files are absent. Recording does not fail: each missing asset is
logged (for example `Locally missing overlay, reusing the previously uploaded
version`) and reused from the previous upload.
If a referenced file is missing locally **and** no previously uploaded version
exists for that video (for example a brand new overlay that has never been
recorded with its file present), the upload fails with a clear message telling
you to record once with the file present, or to commit it. This keeps a video
from silently rendering without an overlay, audio track, or narration clip.
Notes:
- The match is per video and per project. Record a video at least once with each
asset present so a version exists to reuse.
- Overlays are matched by their declared name, so renaming an overlay (or its
file) means the next record needs the file present again.
- Custom voice sample files are the exception: they are read locally at record
time to identify the voice, so keep those committed or present.
- This is independent of `.screenci/`, which is always gitignored and holds the
local recording output.
## Trigger recordings remotely
Because the workflow accepts
+21 -9
View File
@@ -97,21 +97,33 @@ import { autoZoom, hide, video, voices } from 'screenci'
// The default voice (how narration is spoken) for every language.
video.use({ renderOptions: { narration: { voice: { name: voices.Sophie } } } })
// Localized narration cues by language. The fixture exposes them as markers.
video.narration({
en: {
docs: 'Here is where to find ScreenCI [pronounce: screen see eye] docs.',
},
es: {
docs: 'Aqui es donde encontrar la documentacion de ScreenCI [pronounce: screen see eye].',
},
})('How to find docs', async ({ page, narration }) => {
// Localized narration cues by language, plus a brand intro overlay. The fixture
// exposes narration markers and overlay controllers to the body.
//
// The logo image (recordings/assets/logo.png) is gitignored: it is uploaded to
// the ScreenCI backend on the first record and reused on later runs (CI
// included), so the binary does not need to be committed.
video
.overlays({
logo: { path: './assets/logo.png', fill: 'recording', durationMs: 2000 },
})
.narration({
en: {
docs: 'Here is where to find ScreenCI [pronounce: screen see eye] docs.',
},
es: {
docs: 'Aqui es donde encontrar la documentacion de ScreenCI [pronounce: screen see eye].',
},
})('How to find docs', async ({ page, narration, overlays }) => {
// Run setup without showing these actions in the final recording.
await hide(async () => {
await page.goto('https://screenci.com/')
await page.waitForLoadState('networkidle')
})
// Open with a brief brand intro card before the walkthrough begins.
await overlays.logo(2000)
// Play the matching narration line for this step.
await narration.docs()
+5
View File
@@ -150,6 +150,11 @@ speech is generated. Changing it never regenerates the audio, and it is not a
per-language setting: when more than one language sets a volume for the same cue,
the first one wins.
A `media` file is uploaded the first time you record with it present and reused
on later runs, so you do not have to commit the file. If it is missing locally,
ScreenCI reuses the version uploaded for this video (matched by file path). See
[Asset files do not need to be committed](/docs/ci-setup#asset-files-do-not-need-to-be-committed).
## Voice per language and per cue
The default voice for every language is set once with `renderOptions.narration.voice`
+6
View File
@@ -55,6 +55,12 @@ the file and placement for each name configured in the ScreenCI web app. See
For per-language overlay files (e.g. a translated badge image), see
[Languages](/docs/guides/languages).
Overlay files (`.png`, `.mp4`, `.svg`) are uploaded the first time you record
with them present and reused on later runs, so you do not have to commit the
files. If a file is missing locally, ScreenCI reuses the version uploaded for
this video (matched by the overlay's name). See
[Asset files do not need to be committed](/docs/ci-setup#asset-files-do-not-need-to-be-committed).
Rules:
- HTML, React, `.svg`, and `.png` overlays need a `durationMs` for the blocking call form (set it in the config or pass it to the call, for example `await overlays.logo(1200)`). You can omit it when driving the overlay with `start()`/`end()`.
+21 -9
View File
@@ -57,21 +57,33 @@ import { autoZoom, hide, video, voices } from 'screenci'
// The default voice (how narration is spoken) for every language.
video.use({ renderOptions: { narration: { voice: { name: voices.Sophie } } } })
// Localized narration cues by language. The fixture exposes them as markers.
video.narration({
en: {
docs: 'Here is where to find ScreenCI [pronounce: screen see eye] docs.',
},
es: {
docs: 'Aqui es donde encontrar la documentacion de ScreenCI [pronounce: screen see eye].',
},
})('How to find docs', async ({ page, narration }) => {
// Localized narration cues by language, plus a brand intro overlay. The fixture
// exposes narration markers and overlay controllers to the body.
//
// The logo image (recordings/assets/logo.png) is gitignored: it is uploaded to
// the ScreenCI backend on the first record and reused on later runs (CI
// included), so the binary does not need to be committed.
video
.overlays({
logo: { path: './assets/logo.png', fill: 'recording', durationMs: 2000 },
})
.narration({
en: {
docs: 'Here is where to find ScreenCI [pronounce: screen see eye] docs.',
},
es: {
docs: 'Aqui es donde encontrar la documentacion de ScreenCI [pronounce: screen see eye].',
},
})('How to find docs', async ({ page, narration, overlays }) => {
// Run setup without showing these actions in the final recording.
await hide(async () => {
await page.goto('https://screenci.com/')
await page.waitForLoadState('networkidle')
})
// Open with a brief brand intro card before the walkthrough begins.
await overlays.logo(2000)
// Play the matching narration line for this step.
await narration.docs()
+21 -9
View File
@@ -3,21 +3,33 @@ import { autoZoom, hide, video, voices } from 'screenci'
// The default voice (how narration is spoken) for every language.
video.use({ renderOptions: { narration: { voice: { name: voices.Sophie } } } })
// Localized narration cues by language. The fixture exposes them as markers.
video.narration({
en: {
docs: 'Here is where to find ScreenCI [pronounce: screen see eye] docs.',
},
es: {
docs: 'Aqui es donde encontrar la documentacion de ScreenCI [pronounce: screen see eye].',
},
})('How to find docs', async ({ page, narration }) => {
// Localized narration cues by language, plus a brand intro overlay. The fixture
// exposes narration markers and overlay controllers to the body.
//
// The logo image (recordings/assets/logo.png) is gitignored: it is uploaded to
// the ScreenCI backend on the first record and reused on later runs (CI
// included), so the binary does not need to be committed.
video
.overlays({
logo: { path: './assets/logo.png', fill: 'recording', durationMs: 2000 },
})
.narration({
en: {
docs: 'Here is where to find ScreenCI [pronounce: screen see eye] docs.',
},
es: {
docs: 'Aqui es donde encontrar la documentacion de ScreenCI [pronounce: screen see eye].',
},
})('How to find docs', async ({ page, narration, overlays }) => {
// Run setup without showing these actions in the final recording.
await hide(async () => {
await page.goto('https://screenci.com/')
await page.waitForLoadState('networkidle')
})
// Open with a brief brand intro card before the walkthrough begins.
await overlays.logo(2000)
// Play the matching narration line for this step.
await narration.docs()
+2
View File
@@ -54,12 +54,14 @@ describe('public api surface', () => {
'determinePackageManager',
'generateConfig',
'generateExampleVideo',
'generateGitignore',
'generateIslandReadme',
'generateIslandTsconfig',
'generateReactExampleVideo',
'parsePackageManager',
'parsePnpmVersionSupport',
'parseYarnVersionSupport',
'resolveBundledLogoPath',
'runCreateScreenciCli',
'runInit',
'toIslandPackageName',
+2 -1
View File
@@ -45,7 +45,8 @@
"files": [
"bin",
"dist",
"skills"
"skills",
"templates"
],
"scripts": {
"build": "tsc",
+36 -13
View File
@@ -12,7 +12,9 @@ import {
setAssetSleepFn,
validateRegisteredAssetPaths,
resetRegisteredAssetPaths,
resetMissingOverlayWarnings,
} from './asset.js'
import { logger } from './logger.js'
import {
setAnimatedHtmlRasterizer,
setHtmlRasterizer,
@@ -232,23 +234,36 @@ describe('createOverlays', () => {
})
})
it('fails when the overlay file is missing relative to the active test file', async () => {
it('reuses a missing overlay file from a previous upload instead of failing', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'screenci-overlay-spec-'))
resetMissingOverlayWarnings()
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {})
const overlays = createOverlays({
logo: { path: './missing.png', durationMs: 1200 },
})
try {
await expect(
runWithScreenCIRuntimeContext(
createScreenCIRuntimeContext({
recorder,
testFilePath: join(tempDir, 'demo.screenci.ts'),
}),
() => overlays.logo()
)
).rejects.toThrow('Asset file not found: ./missing.png')
await runWithScreenCIRuntimeContext(
createScreenCIRuntimeContext({
recorder,
testFilePath: join(tempDir, 'demo.screenci.ts'),
}),
() => overlays.logo()
)
// The overlay is recorded with no fileHash; the upload step recovers its
// identity from a previous upload of this video.
expect(recorder.addAssetStart).toHaveBeenCalledWith('logo', {
kind: 'image',
path: './missing.png',
durationMs: 1200,
fullScreen: false,
})
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Locally missing overlay: ./missing.png')
)
} finally {
warnSpy.mockRestore()
await rm(tempDir, { recursive: true, force: true })
}
})
@@ -1598,8 +1613,10 @@ describe('validateRegisteredAssetPaths', () => {
}
})
it('throws "Asset file not found" when a registered file is missing', async () => {
it('warns but does not throw when a registered file is missing', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'screenci-asset-validate-'))
resetMissingOverlayWarnings()
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {})
try {
await writeFile(join(tempDir, 'logo.png'), 'png')
const missing = join(tempDir, 'intro.mp4')
@@ -1608,10 +1625,16 @@ describe('validateRegisteredAssetPaths', () => {
intro: { path: missing, fill: 'screen' },
})
await expect(validateRegisteredAssetPaths(ownerFile)).rejects.toThrow(
`Asset file not found: ${missing}`
// A missing overlay file is recovered from a previous upload at upload
// time, so recording does not fail here.
await expect(
validateRegisteredAssetPaths(ownerFile)
).resolves.toBeUndefined()
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(`Locally missing overlay: ${missing}`)
)
} finally {
warnSpy.mockRestore()
await rm(tempDir, { recursive: true, force: true })
}
})
+23 -1
View File
@@ -9,6 +9,7 @@ import {
import { parseTimelineOffset, type TimelineOffset } from './timelineOffset.js'
import { overlayRect } from './overlayRect.js'
import { captureCallerFile } from './callerFile.js'
import { logger } from './logger.js'
import { access, readFile } from 'fs/promises'
import { dirname, resolve } from 'path'
import { resolveRecordingTimingDuration } from './runtimeMode.js'
@@ -443,11 +444,32 @@ async function resolveExistingAssetPath(
throw new Error(`Asset file not found: ${assetPath}`)
}
const warnedMissingOverlayPaths = new Set<string>()
export function resetMissingOverlayWarnings(): void {
warnedMissingOverlayPaths.clear()
}
/**
* Checks that an overlay file exists. A missing file is not fatal: the overlay
* is recovered from a previous upload of this video (matched by name/path) at
* upload time, so a gitignored overlay file does not have to be committed. The
* overlay is composited by the renderer, not into the local recording, so a
* missing file does not change what is captured locally.
*/
async function validateAssetPath(
assetPath: string,
testFilePath: string | null
): Promise<void> {
await resolveExistingAssetPath(assetPath, testFilePath)
try {
await resolveExistingAssetPath(assetPath, testFilePath)
} catch {
if (warnedMissingOverlayPaths.has(assetPath)) return
warnedMissingOverlayPaths.add(assetPath)
logger.warn(
`Locally missing overlay: ${assetPath}. It will be reused from a previous upload of this video if available, otherwise the upload fails.`
)
}
}
/**
+26 -6
View File
@@ -8,7 +8,9 @@ import {
buildStudioAudioTracks,
validateRegisteredAudioPaths,
resetRegisteredAudioPaths,
resetMissingAudioWarnings,
} from './audio.js'
import { logger } from './logger.js'
import { NOOP_EVENT_RECORDER, type IEventRecorder } from './events.js'
import type { RecordingEvent } from './events.js'
import {
@@ -231,13 +233,24 @@ describe('createAudio', () => {
).toThrow('must provide a finite volume')
})
it('throws when the audio file is missing', async () => {
it('records a missing audio file without a hash for upload-time recovery', async () => {
resetMissingAudioWarnings()
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {})
await run(async () => {
const audio = createAudio({ theme: './gone.mp3' })
await expect(audio.theme()).rejects.toThrow(
'Audio file not found for "theme": ./gone.mp3'
await expect(audio.theme()).resolves.toBeUndefined()
// No fileHash is recorded; the track is recovered from a previous upload
// of this video at upload time.
expect(recorder.addAudioStart).toHaveBeenCalledWith('theme', {
path: './gone.mp3',
volume: 1,
repeat: false,
})
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Locally missing audio: ./gone.mp3')
)
})
warnSpy.mockRestore()
})
it('is a no-op against the noop recorder outside a recording', async () => {
@@ -344,8 +357,10 @@ describe('validateRegisteredAudioPaths', () => {
}
})
it('throws "Audio file not found" when a registered file is missing', async () => {
it('warns but does not throw when a registered file is missing', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'screenci-audio-validate-'))
resetMissingAudioWarnings()
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {})
try {
await writeFile(join(tempDir, 'theme.mp3'), 'mp3')
const missing = join(tempDir, 'sting.wav')
@@ -354,10 +369,15 @@ describe('validateRegisteredAudioPaths', () => {
sting: missing,
})
await expect(validateRegisteredAudioPaths(ownerFile)).rejects.toThrow(
`Audio file not found: ${missing}`
// A missing audio file is recovered from a previous upload at upload time.
await expect(
validateRegisteredAudioPaths(ownerFile)
).resolves.toBeUndefined()
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(`Locally missing audio: ${missing}`)
)
} finally {
warnSpy.mockRestore()
await rm(tempDir, { recursive: true, force: true })
}
})
+34 -14
View File
@@ -5,6 +5,7 @@ import { createHash } from 'crypto'
import { dirname, resolve } from 'path'
import { captureCallerFile } from './callerFile.js'
import { isInsideHide } from './hide.js'
import { logger } from './logger.js'
import { MAX_AUDIO_LEVEL, validateSpeedTime } from './asset.js'
import {
getScreenCIRuntimeContext,
@@ -127,30 +128,47 @@ export function resetRegisteredAudioPaths(): void {
registeredAudio.length = 0
}
const warnedMissingAudioPaths = new Set<string>()
function warnMissingAudio(path: string): void {
if (warnedMissingAudioPaths.has(path)) return
warnedMissingAudioPaths.add(path)
logger.warn(
`Locally missing audio: ${path}. It will be reused from a previous upload of this video if available, otherwise the upload fails.`
)
}
export function resetMissingAudioWarnings(): void {
warnedMissingAudioPaths.clear()
}
/**
* Validates the audio files declared by the `.screenci` script at
* {@link testFilePath} (plus any unattributed registrations), resolving each
* as-is and relative to that file. Throws "Audio file not found" on the first
* missing file. Wired into the record flow so missing audio is caught before
* the test body runs. Tracks attributed to a different test file are skipped.
* as-is and relative to that file. A missing file is not fatal: it is reused
* from a previous upload of this video (matched by path) at upload time, so a
* gitignored audio file does not have to be committed. Tracks attributed to a
* different test file are skipped.
*/
export async function validateRegisteredAudioPaths(
testFilePath: string | null
): Promise<void> {
for (const { ownerFile, path } of registeredAudio) {
if (ownerFile !== null && ownerFile !== testFilePath) continue
await resolveExistingAudioPath(path, testFilePath)
if ((await resolveExistingAudioPath(path, testFilePath)) === null) {
warnMissingAudio(path)
}
}
}
/**
* Resolves an audio file path to an existing path, trying it as-is and relative
* to the test file. Throws "Audio file not found" when no candidate exists.
* to the test file. Returns null when no candidate exists.
*/
async function resolveExistingAudioPath(
path: string,
testFilePath: string | null
): Promise<string> {
): Promise<string | null> {
const candidates = [path]
if (testFilePath !== null) {
candidates.push(resolve(dirname(testFilePath), path))
@@ -163,7 +181,7 @@ async function resolveExistingAudioPath(
// try next candidate
}
}
throw new Error(`[screenci] Audio file not found: ${path}`)
return null
}
export function setActiveAudioRecorder(recorder: IEventRecorder | null): void {
@@ -184,14 +202,15 @@ function hasAudioExtension(path: string): boolean {
/**
* Resolves an audio file path (as-is or relative to the test file) and returns
* its absolute path plus a SHA-256 hash for upload/caching. Throws when the file
* does not exist.
* its path plus a SHA-256 hash for upload/caching. When the file is absent the
* hash is undefined: the track is recovered from a previous upload of this video
* (matched by path) at upload time, so a gitignored audio file need not be
* committed.
*/
async function resolveAudioFile(
name: string,
path: string,
testFilePath: string | null
): Promise<{ path: string; fileHash: string }> {
): Promise<{ path: string; fileHash?: string }> {
const candidates = [path]
if (testFilePath !== null) {
candidates.push(resolve(dirname(testFilePath), path))
@@ -207,7 +226,8 @@ async function resolveAudioFile(
// try next candidate
}
}
throw new Error(`[screenci] Audio file not found for "${name}": ${path}`)
warnMissingAudio(path)
return { path }
}
function normalizeAudioConfig(name: string, input: AudioInput): AudioConfig {
@@ -383,10 +403,10 @@ function buildAudioController(
const buildPayload = async (): Promise<AudioStartPayload> => {
const testFilePath = getScreenCIRuntimeContext().testFilePath
const resolved = await resolveAudioFile(name, config.path, testFilePath)
const resolved = await resolveAudioFile(config.path, testFilePath)
return {
path: resolved.path,
fileHash: resolved.fileHash,
...(resolved.fileHash !== undefined && { fileHash: resolved.fileHash }),
volume: config.volume ?? 1,
repeat: config.repeat ?? false,
...(config.speed !== undefined && { speed: config.speed }),
+27 -2
View File
@@ -11,6 +11,7 @@ import {
setSleepFn,
validateCustomVoiceRefs,
assertNarrationLanguagesMatch,
resetMissingNarrationAssetWarnings,
} from './cue.js'
import * as screenci from '../index.js'
import { hide, setActiveHideRecorder } from './hide.js'
@@ -755,6 +756,8 @@ describe('createNarration', () => {
})
it('supports cue objects with text and media fields', async () => {
resetMissingNarrationAssetWarnings()
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {})
const cues = createNarration({
voice: { name: voices.Ava },
en: {
@@ -768,9 +771,31 @@ describe('createNarration', () => {
},
})
await expect(cues.intro.start()).rejects.toThrow(
'Asset file not found: /tmp/intro-en.mp4'
// The media file is missing locally, so it is recorded without a hash
// (recovered from a previous upload at upload time) rather than failing.
await runWithScreenCIRuntimeContext(
createScreenCIRuntimeContext({
testFilePath: fileURLToPath(import.meta.url),
}),
async () => {
setActiveCueRecorder(recorder)
await expect(cues.intro.start()).resolves.toBeUndefined()
}
)
const translations = (
recorder.addVideoCueStart as ReturnType<typeof vi.fn>
).mock.calls[0]?.[4] as Record<string, unknown>
expect(translations.en).toEqual({
assetPath: '/tmp/intro-en.mp4',
subtitle: 'Intro subtitle',
})
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Locally missing narration media: /tmp/intro-en.mp4'
)
)
warnSpy.mockRestore()
})
it('allows custom voice refs before validation and resolves them at start', async () => {
+32 -4
View File
@@ -83,10 +83,30 @@ export async function validateCustomVoiceRefs(
// pre-validate here.
}
const warnedMissingAssetPaths = new Set<string>()
function warnMissingNarrationAsset(assetPath: string): void {
if (warnedMissingAssetPaths.has(assetPath)) return
warnedMissingAssetPaths.add(assetPath)
logger.warn(
`Locally missing narration media: ${assetPath}. It will be reused from a previous upload of this video if available, otherwise the upload fails.`
)
}
export function resetMissingNarrationAssetWarnings(): void {
warnedMissingAssetPaths.clear()
}
/**
* Hashes a narration media file for upload and caching. Returns undefined when
* the file is absent locally: the asset is then recovered from a previous upload
* of this video (matched by path) at upload time, so a gitignored media file
* does not have to be committed. See resolveMissingUploadAssets in cli.ts.
*/
async function resolveAssetFileHash(
assetPath: string,
testFilePath: string | null
): Promise<string> {
): Promise<string | undefined> {
const candidates = [assetPath]
if (testFilePath !== null) {
const testDir = dirname(testFilePath)
@@ -102,7 +122,7 @@ async function resolveAssetFileHash(
}
}
throw new Error(`Asset file not found: ${assetPath}`)
return undefined
}
async function toRecordedVoice(
@@ -110,8 +130,14 @@ async function toRecordedVoice(
): Promise<VoiceKey | RecordingCustomVoiceRef> {
if (!isCustomVoiceRef(voice)) return voice
const testFilePath = getScreenCIRuntimeContext().testFilePath
// A custom voice sample is read at record time to identify it for cloning, so
// unlike narration media it must be present locally.
const assetHash = await resolveAssetFileHash(voice.path, testFilePath)
if (assetHash === undefined) {
throw new Error(`Asset file not found: ${voice.path}`)
}
return {
assetHash: await resolveAssetFileHash(voice.path, testFilePath),
assetHash,
assetPath: voice.path,
}
}
@@ -873,8 +899,10 @@ async function entryToVideoTranslation(
): Promise<VideoCueTranslationFile> {
const path = typeof entry === 'string' ? entry : entry.path
const subtitle = typeof entry === 'string' ? undefined : entry.subtitle
const assetHash = await resolveAssetFileHash(path, testFilePath)
if (assetHash === undefined) warnMissingNarrationAsset(path)
return {
assetHash: await resolveAssetFileHash(path, testFilePath),
...(assetHash !== undefined && { assetHash }),
assetPath: path,
...(subtitle !== undefined && { subtitle }),
}
+10 -6
View File
@@ -288,12 +288,16 @@ export type ValuesDeclareEvent = {
/** File-based video cue translation. assetPath is present only in the local
* recording phase (for CLI upload) and is stripped before submitting to the backend. */
export type VideoCueTranslationFile = {
assetHash: string
/** Local file path — present only during recording; stripped from submitted data. */
assetPath?: string
subtitle?: string
}
/**
* A file-based video cue translation. During recording it carries the local
* `assetPath` (with `assetHash` present only when the file was found locally);
* the path is stripped before submission, leaving just `assetHash`. The
* recovered-from-a-previous-upload case is the recording-phase shape with no
* `assetHash` yet (filled in from a previous upload before submission).
*/
export type VideoCueTranslationFile =
| { assetPath: string; assetHash?: string; subtitle?: string }
| { assetHash: string; assetPath?: string; subtitle?: string }
/** TTS-based video cue translation — generates audio via text-to-speech. */
export type VideoCueTranslationTTS = {
text: string
+62 -5
View File
@@ -1,4 +1,12 @@
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -7,10 +15,12 @@ import {
createInitLinkSession,
generateConfig,
generateExampleVideo,
generateGitignore,
generateIslandReadme,
generateIslandTsconfig,
generateReactExampleVideo,
parsePnpmVersionSupport,
resolveBundledLogoPath,
toIslandPackageName,
} from './init.js'
@@ -119,14 +129,61 @@ describe('generateExampleVideo', () => {
expect(source).toContain(
`video.use({ renderOptions: { narration: { voice: { name: voices.Sophie } } } })`
)
expect(source)
.toContain(`// Localized narration cues by language. The fixture exposes them as markers.
video.narration({
en: {`)
expect(source).toContain(` .narration({
en: {`)
// The all-languages default voice lives in use, not in the per-feature spec.
expect(source).not.toContain('video.localize(')
expect(source).not.toContain('createNarration')
})
it('declares a logo overlay and shows it as an intro card', () => {
const source = generateExampleVideo()
// The overlay is declared from the bundled, gitignored asset path.
expect(source).toContain('video\n .overlays({')
expect(source).toContain(
"logo: { path: './assets/logo.png', fill: 'recording', durationMs: 2000 }"
)
// The body receives the overlay controllers and opens with the logo card.
expect(source).toContain(
"})('How to find docs', async ({ page, narration, overlays }) => {"
)
expect(source).toContain('await overlays.logo(2000)')
// A comment explains the asset is gitignored and need not be committed.
expect(source).toContain('is gitignored')
})
})
describe('generateGitignore', () => {
it('ignores the video asset media folder with an explanatory comment', () => {
const gitignore = generateGitignore()
expect(gitignore).toContain('recordings/assets/')
expect(gitignore).toContain('uploaded to')
// The path must match where the scaffold writes assets.
expect(gitignore).toContain('# Video asset media')
})
})
describe('resolveBundledLogoPath', () => {
it('resolves to an existing, non-empty logo.png', () => {
const logoPath = resolveBundledLogoPath()
expect(existsSync(logoPath)).toBe(true)
expect(path.basename(logoPath)).toBe('logo.png')
expect(statSync(logoPath).size).toBeGreaterThan(0)
})
it('copies a non-empty logo.png into recordings/assets (as the scaffold does)', () => {
const tempDir = mkdtempSync(path.join(tmpdir(), 'screenci-logo-'))
try {
const assetsDir = path.join(tempDir, 'recordings', 'assets')
mkdirSync(assetsDir, { recursive: true })
const target = path.join(assetsDir, 'logo.png')
copyFileSync(resolveBundledLogoPath(), target)
expect(existsSync(target)).toBe(true)
expect(statSync(target).size).toBeGreaterThan(0)
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
})
})
describe('generateIslandTsconfig', () => {
+66 -11
View File
@@ -1,7 +1,7 @@
import { spawn } from 'child_process'
import type { ChildProcess } from 'child_process'
import { existsSync, readFileSync, realpathSync, rmSync } from 'fs'
import { appendFile, mkdir, readFile, writeFile } from 'fs/promises'
import { appendFile, copyFile, mkdir, readFile, writeFile } from 'fs/promises'
import { basename, delimiter, dirname, relative, resolve, sep } from 'path'
import { fileURLToPath } from 'url'
import { Command, CommanderError } from 'commander'
@@ -927,6 +927,34 @@ async function ensureSupportedYarnVersion(cwd: string): Promise<void> {
}
}
// Resolve the bundled template logo PNG. It ships under the package's
// `templates/` directory (declared in package.json "files"), so the same lookup
// must work both from `src/init.ts` during tests and from the compiled
// `dist/src/init.js` once published. tsc copies no binary assets, so resolve the
// asset by an absolute path relative to this module's location, trying both
// depths and returning the first that exists (mirroring the bundled-shim lookup
// in cli.ts).
export function resolveBundledLogoPath(): string {
const currentFileDir = dirname(fileURLToPath(import.meta.url))
const relativeAssetPath = ['templates', 'recordings', 'assets', 'logo.png']
const candidates = [
// From src/init.ts (tests): packages/screenci/templates/...
resolve(currentFileDir, '..', ...relativeAssetPath),
// From dist/src/init.js (published): packages/screenci/templates/...
resolve(currentFileDir, '..', '..', ...relativeAssetPath),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
// Fall back to the src-relative path so callers get a clear ENOENT pointing at
// the expected location if the asset is somehow missing.
return candidates[0] ?? resolve(currentFileDir, '..', ...relativeAssetPath)
}
async function readCurrentScreenciVersion(): Promise<string> {
const currentFileDir = dirname(fileURLToPath(import.meta.url))
const packageJsonPaths = [
@@ -952,13 +980,21 @@ async function readCurrentScreenciVersion(): Promise<string> {
return 'latest'
}
function generateGitignore(packageManager: PackageManager = 'npm'): string {
export function generateGitignore(
packageManager: PackageManager = 'npm'
): string {
const yarnSection = packageManager === 'yarn' ? '\n# Yarn\n.yarn/\n' : ''
return `# ScreenCI
.screenci
.playwright-cli/
.env
# Video asset media (overlays, audio, narration clips). These are uploaded to
# the ScreenCI backend on first record and reused on later runs (CI included),
# so the large media files do not need to be committed. Delete this rule if you
# prefer to commit them.
recordings/assets/
# Playwright
node_modules/
/test-results/
@@ -1248,21 +1284,33 @@ export function generateExampleVideo(): string {
// The default voice (how narration is spoken) for every language.
video.use({ renderOptions: { narration: { voice: { name: voices.Sophie } } } })
// Localized narration cues by language. The fixture exposes them as markers.
video.narration({
en: {
docs: 'Here is where to find ScreenCI [pronounce: screen see eye] docs.',
},
es: {
docs: 'Aqui es donde encontrar la documentacion de ScreenCI [pronounce: screen see eye].',
},
})('How to find docs', async ({ page, narration }) => {
// Localized narration cues by language, plus a brand intro overlay. The fixture
// exposes narration markers and overlay controllers to the body.
//
// The logo image (recordings/assets/logo.png) is gitignored: it is uploaded to
// the ScreenCI backend on the first record and reused on later runs (CI
// included), so the binary does not need to be committed.
video
.overlays({
logo: { path: './assets/logo.png', fill: 'recording', durationMs: 2000 },
})
.narration({
en: {
docs: 'Here is where to find ScreenCI [pronounce: screen see eye] docs.',
},
es: {
docs: 'Aqui es donde encontrar la documentacion de ScreenCI [pronounce: screen see eye].',
},
})('How to find docs', async ({ page, narration, overlays }) => {
// Run setup without showing these actions in the final recording.
await hide(async () => {
await page.goto('https://screenci.com/')
await page.waitForLoadState('networkidle')
})
// Open with a brief brand intro card before the walkthrough begins.
await overlays.logo(2000)
// Play the matching narration line for this step.
await narration.docs()
@@ -1577,6 +1625,13 @@ export async function runInit(
await mkdir(resolve(islandDir, 'recordings'), { recursive: true })
islandCreated = true
// Ship the brand intro logo used by the example script. It lands in the
// gitignored assets folder: the example uploads it to the backend on first
// record and reuses it afterwards, so it need not be committed.
const assetsDir = resolve(islandDir, 'recordings', 'assets')
await mkdir(assetsDir, { recursive: true })
await copyFile(resolveBundledLogoPath(), resolve(assetsDir, 'logo.png'))
await writeFile(
resolve(islandDir, 'screenci.config.ts'),
generateConfig(projectName)
+6 -5
View File
@@ -165,11 +165,12 @@ export type CueEndEvent = {
reason?: 'auto' | 'wait'
}
export type VideoCueTranslationFile = {
assetHash: string
assetPath?: string
subtitle?: string
}
// During recording this carries the local assetPath (assetHash present only when
// the file was found locally); the path is stripped before submission, leaving
// just assetHash.
export type VideoCueTranslationFile =
| { assetPath: string; assetHash?: string; subtitle?: string }
| { assetHash: string; assetPath?: string; subtitle?: string }
export type VideoCueTranslationTTS = {
text: string
+17
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
isTimingDebugEnabled,
isUploadExistingEnabled,
mergeStudioRecordOptions,
parseRecordOptions,
parseRequestedLanguages,
@@ -53,6 +54,22 @@ describe('runtimeMode', () => {
expect(shouldSimulateRecordingTimings(env)).toBe(true)
expect(resolveRecordingTimingDuration(500, env)).toBe(500)
})
it('enables upload-existing only when UPLOAD_EXISTING is truthy', () => {
expect(isUploadExistingEnabled({} as NodeJS.ProcessEnv)).toBe(false)
expect(
isUploadExistingEnabled({ UPLOAD_EXISTING: 'true' } as NodeJS.ProcessEnv)
).toBe(true)
expect(
isUploadExistingEnabled({ UPLOAD_EXISTING: '1' } as NodeJS.ProcessEnv)
).toBe(true)
expect(
isUploadExistingEnabled({ UPLOAD_EXISTING: '0' } as NodeJS.ProcessEnv)
).toBe(false)
expect(
isUploadExistingEnabled({ UPLOAD_EXISTING: 'false' } as NodeJS.ProcessEnv)
).toBe(false)
})
})
describe('parseRequestedLanguages', () => {
+16
View File
@@ -8,6 +8,22 @@ export const SCREENCI_RECORD_OPTIONS_ENV = 'SCREENCI_RECORD_OPTIONS'
export const SCREENCI_DISABLE_RECORDING_TIMINGS_ENV =
'SCREENCI_DISABLE_RECORDING_TIMINGS'
export const SCREENCI_DEBUG_TIMING_ENV = 'SCREENCI_DEBUG_TIMING'
export const SCREENCI_UPLOAD_EXISTING_ENV = 'UPLOAD_EXISTING'
/**
* When set, `screenci record` skips the Playwright recording run entirely and
* re-uploads whatever is already on disk under `.screenci`. Useful for resending
* the most recent local recordings when only the upload failed, without paying
* to re-record. Internal/debug-only: not surfaced as a documented flag.
*/
export function isUploadExistingEnabled(
env: NodeJS.ProcessEnv = process.env
): boolean {
return (
env[SCREENCI_UPLOAD_EXISTING_ENV] === 'true' ||
env[SCREENCI_UPLOAD_EXISTING_ENV] === '1'
)
}
/**
* When set, screenci logs a per-phase timing breakdown for each interaction so
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB