Files
Olli Paloviita 8686c6f752 Export always re-records; cheap duplicate-editId pre-check
export drops the freshness partition and --force: sources can carry
changes the freshness hash does not see, and export is the moment to
capture them, so every requested video re-records with the export flag.
Kept source hashes rebaseline after apply so previews stay accurate.
Duplicate-editId resolution now does a cheap textual pre-check, so a
missing TypeScript only warns when there is actually something to
resolve. Docs updated.
2026-08-19 15:28:43 +03:00

252 lines
7.1 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import {
downloadExportOutputs,
exportExitCode,
exportFileName,
pollExportRenders,
type ExportInfoResponse,
type ExportRenderResult,
} from './src/exportRun'
function infoResponse(
entries: Record<
string,
Record<
string,
{
status: 'finished' | 'rendering' | 'failed'
failureMessage?: string
}
>
>
): ExportInfoResponse {
return {
videos: Object.fromEntries(
Object.entries(entries).map(([videoName, languages]) => [
videoName,
{
videoId: `vid-${videoName}`,
languages: Object.fromEntries(
Object.entries(languages).map(([language, latest]) => [
language,
{
latestRecord: {
...latest,
...(latest.status === 'finished' && {
download: {
video: `https://api/dl/${videoName}/${language}/video`,
screenshot: `https://api/dl/${videoName}/${language}/screenshot`,
},
}),
},
},
])
),
},
])
),
}
}
describe('pollExportRenders', () => {
it('polls until every language reports a terminal status', async () => {
const responses = [
infoResponse({ Login: { en: { status: 'rendering' } } }),
infoResponse({
Login: { en: { status: 'finished' }, fi: { status: 'finished' } },
}),
]
let call = 0
const sleep = vi.fn(async () => {})
const results = await pollExportRenders({
targets: [{ recordId: 'rec_1', videoNames: ['Login'] }],
intervalMs: 5000,
maxAttempts: 10,
deps: {
fetchInfo: async () => responses[Math.min(call++, 1)]!,
sleep,
log: () => {},
},
})
expect(sleep).toHaveBeenCalledWith(5000)
expect(
results.map((r) => `${r.videoName}/${r.language}/${r.status}`)
).toEqual(['Login/en/finished', 'Login/fi/finished'])
expect(results[0]?.downloadUrl).toBe('https://api/dl/Login/en/video')
})
it('reports failures with their message', async () => {
const results = await pollExportRenders({
targets: [{ recordId: 'rec_1', videoNames: ['Login'] }],
intervalMs: 1,
maxAttempts: 2,
deps: {
fetchInfo: async () =>
infoResponse({
Login: {
en: { status: 'failed', failureMessage: 'Render exploded' },
},
}),
sleep: async () => {},
log: () => {},
},
})
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
status: 'failed',
failureMessage: 'Render exploded',
})
})
it('restricts the watched languages to the requested filter', async () => {
const results = await pollExportRenders({
targets: [{ recordId: 'rec_1', videoNames: ['Login'] }],
languages: ['fi'],
intervalMs: 1,
maxAttempts: 3,
deps: {
fetchInfo: async () =>
infoResponse({
Login: { en: { status: 'rendering' }, fi: { status: 'finished' } },
}),
sleep: async () => {},
log: () => {},
},
})
expect(results).toEqual([
expect.objectContaining({ language: 'fi', status: 'finished' }),
])
})
it('times out a video that never reports a render', async () => {
const results = await pollExportRenders({
targets: [{ recordId: 'rec_1', videoNames: ['Login'] }],
intervalMs: 1,
maxAttempts: 3,
deps: {
fetchInfo: async () => ({ videos: {} }),
sleep: async () => {},
log: () => {},
},
})
expect(results).toEqual([
expect.objectContaining({ videoName: 'Login', status: 'timeout' }),
])
})
})
describe('exportFileName', () => {
it('sanitizes the title and appends language and extension', () => {
expect(exportFileName('Login flow', 'en', 'mp4')).toBe(
`${'Login flow'.replace(/[^a-zA-Z0-9 _-]/g, '')}.en.mp4`.replace(/^/, '')
)
// Path separators never leak into the file name.
expect(exportFileName('a/b', 'en', 'mp4')).not.toContain('/')
})
})
function finishedResult(
videoName: string,
language = 'en'
): ExportRenderResult {
return {
videoName,
language,
status: 'finished',
downloadUrl: `https://api/dl/${videoName}/${language}/video`,
screenshotUrl: `https://api/dl/${videoName}/${language}/screenshot`,
}
}
describe('downloadExportOutputs', () => {
it('downloads each finished render into the output directory', async () => {
const written = new Map<string, Uint8Array>()
const downloads = await downloadExportOutputs({
results: [finishedResult('Login'), finishedResult('Signup')],
outDir: '/exports',
deps: {
fetchFn: async () => ({
ok: true,
status: 200,
arrayBuffer: async () => new TextEncoder().encode('bytes').buffer,
}),
mkdir: async () => {},
writeFile: async (path, data) => {
written.set(path, data)
},
},
})
expect(downloads.map((d) => d.filePath)).toEqual([
expect.stringContaining('Login.en.mp4'),
expect.stringContaining('Signup.en.mp4'),
])
expect(written.size).toBe(2)
})
it('falls back to the screenshot URL when the video URL 404s', async () => {
const downloads = await downloadExportOutputs({
results: [finishedResult('Shot')],
outDir: '/exports',
deps: {
fetchFn: async (url) =>
url.endsWith('/video')
? {
ok: false,
status: 404,
arrayBuffer: async () => new ArrayBuffer(0),
}
: {
ok: true,
status: 200,
arrayBuffer: async () => new ArrayBuffer(1),
},
mkdir: async () => {},
writeFile: async () => {},
},
})
expect(downloads[0]?.filePath).toContain('Shot.en.png')
})
it('reports a failed download without throwing', async () => {
const downloads = await downloadExportOutputs({
results: [finishedResult('Login')],
outDir: '/exports',
deps: {
fetchFn: async () => ({
ok: false,
status: 500,
arrayBuffer: async () => new ArrayBuffer(0),
}),
mkdir: async () => {},
writeFile: async () => {},
},
})
expect(downloads[0]).toMatchObject({ filePath: null })
expect(downloads[0]?.error).toContain('500')
})
})
describe('exportExitCode', () => {
it('is 0 only when everything finished and downloaded', () => {
const results = [finishedResult('Login')]
expect(
exportExitCode(results, [
{ videoName: 'Login', language: 'en', filePath: '/e/Login.en.mp4' },
])
).toBe(0)
})
it('is 1 on failure, timeout, empty results, or a failed download', () => {
expect(exportExitCode([], [])).toBe(1)
expect(
exportExitCode([{ ...finishedResult('Login'), status: 'failed' }], [])
).toBe(1)
expect(
exportExitCode(
[finishedResult('Login')],
[{ videoName: 'Login', language: 'en', filePath: null }]
)
).toBe(1)
})
})