mirror of
https://github.com/screenci/screenci.git
synced 2026-09-19 08:57:46 +08:00
feat: reuse uploaded asset media
This commit is contained in:
@@ -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"')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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()`.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -54,12 +54,14 @@ describe('public api surface', () => {
|
||||
'determinePackageManager',
|
||||
'generateConfig',
|
||||
'generateExampleVideo',
|
||||
'generateGitignore',
|
||||
'generateIslandReadme',
|
||||
'generateIslandTsconfig',
|
||||
'generateReactExampleVideo',
|
||||
'parsePackageManager',
|
||||
'parsePnpmVersionSupport',
|
||||
'parseYarnVersionSupport',
|
||||
'resolveBundledLogoPath',
|
||||
'runCreateScreenciCli',
|
||||
'runInit',
|
||||
'toIslandPackageName',
|
||||
|
||||
+2
-1
@@ -45,7 +45,8 @@
|
||||
"files": [
|
||||
"bin",
|
||||
"dist",
|
||||
"skills"
|
||||
"skills",
|
||||
"templates"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
|
||||
+36
-13
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user