mirror of
https://github.com/screenci/screenci.git
synced 2026-09-19 08:57:46 +08:00
chore: update screenci
This commit is contained in:
@@ -19,6 +19,7 @@ const mockCreateReadStream = vi.fn()
|
||||
const mockAppendFile = vi.fn()
|
||||
const mockWriteFile = vi.fn()
|
||||
const mockMkdir = vi.fn()
|
||||
const mockRm = vi.fn()
|
||||
const mockInput = vi.fn()
|
||||
const mockConfirm = vi.fn()
|
||||
const mockCreateHttpServer = vi.fn()
|
||||
@@ -187,6 +188,7 @@ vi.mock('fs', () => ({
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
appendFile: mockAppendFile,
|
||||
rm: mockRm,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
@@ -194,6 +196,7 @@ vi.mock('fs/promises', () => ({
|
||||
mkdir: mockMkdir,
|
||||
default: {
|
||||
appendFile: mockAppendFile,
|
||||
rm: mockRm,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
@@ -236,6 +239,7 @@ describe('CLI', () => {
|
||||
mockAppendFile.mockResolvedValue(undefined)
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
mockMkdir.mockResolvedValue(undefined)
|
||||
mockRm.mockResolvedValue(undefined)
|
||||
mockReaddir.mockResolvedValue([])
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
if (process.env.VITE_APP_BASE_URL === undefined) {
|
||||
|
||||
@@ -19,6 +19,7 @@ const mockCreateReadStream = vi.fn()
|
||||
const mockAppendFile = vi.fn()
|
||||
const mockWriteFile = vi.fn()
|
||||
const mockMkdir = vi.fn()
|
||||
const mockRm = vi.fn()
|
||||
const mockInput = vi.fn()
|
||||
const mockConfirm = vi.fn()
|
||||
const mockCreateHttpServer = vi.fn()
|
||||
@@ -187,6 +188,7 @@ vi.mock('fs', () => ({
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
appendFile: mockAppendFile,
|
||||
rm: mockRm,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
@@ -194,6 +196,7 @@ vi.mock('fs/promises', () => ({
|
||||
mkdir: mockMkdir,
|
||||
default: {
|
||||
appendFile: mockAppendFile,
|
||||
rm: mockRm,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
@@ -236,6 +239,7 @@ describe('CLI', () => {
|
||||
mockAppendFile.mockResolvedValue(undefined)
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
mockMkdir.mockResolvedValue(undefined)
|
||||
mockRm.mockResolvedValue(undefined)
|
||||
mockReaddir.mockResolvedValue([])
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
if (process.env.VITE_APP_BASE_URL === undefined) {
|
||||
|
||||
@@ -22,6 +22,7 @@ const mockAppendFile = vi.fn()
|
||||
const mockWriteFile = vi.fn()
|
||||
const mockCopyFile = vi.fn()
|
||||
const mockMkdir = vi.fn()
|
||||
const mockRm = vi.fn()
|
||||
const mockInput = vi.fn()
|
||||
const mockConfirm = vi.fn()
|
||||
const mockCreateHttpServer = vi.fn()
|
||||
@@ -216,6 +217,7 @@ vi.mock('fs', () => ({
|
||||
vi.mock('fs/promises', () => ({
|
||||
appendFile: mockAppendFile,
|
||||
copyFile: mockCopyFile,
|
||||
rm: mockRm,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
@@ -224,6 +226,7 @@ vi.mock('fs/promises', () => ({
|
||||
default: {
|
||||
appendFile: mockAppendFile,
|
||||
copyFile: mockCopyFile,
|
||||
rm: mockRm,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
@@ -267,6 +270,7 @@ describe('CLI', () => {
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
mockCopyFile.mockResolvedValue(undefined)
|
||||
mockMkdir.mockResolvedValue(undefined)
|
||||
mockRm.mockResolvedValue(undefined)
|
||||
mockReaddir.mockResolvedValue([])
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
if (process.env.VITE_APP_BASE_URL === undefined) {
|
||||
|
||||
@@ -19,6 +19,7 @@ const mockCreateReadStream = vi.fn()
|
||||
const mockAppendFile = vi.fn()
|
||||
const mockWriteFile = vi.fn()
|
||||
const mockMkdir = vi.fn()
|
||||
const mockRm = vi.fn()
|
||||
const mockInput = vi.fn()
|
||||
const mockConfirm = vi.fn()
|
||||
const mockCreateHttpServer = vi.fn()
|
||||
@@ -187,6 +188,7 @@ vi.mock('fs', () => ({
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
appendFile: mockAppendFile,
|
||||
rm: mockRm,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
@@ -194,6 +196,7 @@ vi.mock('fs/promises', () => ({
|
||||
mkdir: mockMkdir,
|
||||
default: {
|
||||
appendFile: mockAppendFile,
|
||||
rm: mockRm,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
@@ -236,6 +239,7 @@ describe('CLI', () => {
|
||||
mockAppendFile.mockResolvedValue(undefined)
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
mockMkdir.mockResolvedValue(undefined)
|
||||
mockRm.mockResolvedValue(undefined)
|
||||
mockReaddir.mockResolvedValue([])
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
if (process.env.VITE_APP_BASE_URL === undefined) {
|
||||
|
||||
+398
-9
@@ -26,6 +26,7 @@ const mockCreateReadStream = vi.fn()
|
||||
const mockAppendFile = vi.fn()
|
||||
const mockWriteFile = vi.fn()
|
||||
const mockMkdir = vi.fn()
|
||||
const mockRm = vi.fn()
|
||||
const mockInput = vi.fn()
|
||||
const mockConfirm = vi.fn()
|
||||
const mockCreateHttpServer = vi.fn()
|
||||
@@ -194,6 +195,7 @@ vi.mock('fs', () => ({
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
appendFile: mockAppendFile,
|
||||
rm: mockRm,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
@@ -201,6 +203,7 @@ vi.mock('fs/promises', () => ({
|
||||
mkdir: mockMkdir,
|
||||
default: {
|
||||
appendFile: mockAppendFile,
|
||||
rm: mockRm,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
@@ -243,6 +246,7 @@ describe('CLI', () => {
|
||||
mockAppendFile.mockResolvedValue(undefined)
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
mockMkdir.mockResolvedValue(undefined)
|
||||
mockRm.mockResolvedValue(undefined)
|
||||
mockReaddir.mockResolvedValue([])
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
if (process.env.VITE_APP_BASE_URL === undefined) {
|
||||
@@ -410,6 +414,130 @@ describe('CLI', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('acquireRecordRunLock', () => {
|
||||
it('refuses a fresh lock whose pid is still alive', async () => {
|
||||
const addSignalListener = vi.fn()
|
||||
const removeSignalListener = vi.fn()
|
||||
const { acquireRecordRunLock } = await import('./cli')
|
||||
|
||||
await expect(
|
||||
acquireRecordRunLock('/repo/.screenci', 'Test Project', {
|
||||
pid: 123,
|
||||
clock: () => new Date('2026-07-05T10:05:00.000Z'),
|
||||
isPidAlive: (pid) => pid === 456,
|
||||
fs: {
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
readFile: vi.fn().mockResolvedValue(
|
||||
JSON.stringify({
|
||||
pid: 456,
|
||||
startedAt: '2026-07-05T10:00:00.000Z',
|
||||
projectName: 'Other Project',
|
||||
})
|
||||
),
|
||||
writeFile: vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('exists'), { code: 'EEXIST' })
|
||||
),
|
||||
rm: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
addSignalListener,
|
||||
removeSignalListener,
|
||||
removeLockSync: vi.fn(),
|
||||
})
|
||||
).rejects.toThrow(
|
||||
`Another 'screenci record' is in progress (pid 456, started 2026-07-05T10:00:00.000Z, project "Other Project"). Wait for it or remove .screenci/.record.lock.`
|
||||
)
|
||||
|
||||
expect(addSignalListener).not.toHaveBeenCalled()
|
||||
expect(removeSignalListener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reclaims a stale lock when the pid is dead', async () => {
|
||||
const mockWriteFile = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('exists'), { code: 'EEXIST' })
|
||||
)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const mockRm = vi.fn().mockResolvedValue(undefined)
|
||||
const { acquireRecordRunLock } = await import('./cli')
|
||||
|
||||
const lock = await acquireRecordRunLock(
|
||||
'/repo/.screenci',
|
||||
'Test Project',
|
||||
{
|
||||
pid: 123,
|
||||
clock: () => new Date('2026-07-05T10:05:00.000Z'),
|
||||
isPidAlive: () => false,
|
||||
fs: {
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
readFile: vi.fn().mockResolvedValue(
|
||||
JSON.stringify({
|
||||
pid: 456,
|
||||
startedAt: '2026-07-05T10:00:00.000Z',
|
||||
projectName: 'Other Project',
|
||||
})
|
||||
),
|
||||
writeFile: mockWriteFile,
|
||||
rm: mockRm,
|
||||
},
|
||||
addSignalListener: vi.fn(),
|
||||
removeSignalListener: vi.fn(),
|
||||
removeLockSync: vi.fn(),
|
||||
}
|
||||
)
|
||||
|
||||
expect(mockRm).toHaveBeenCalledWith('/repo/.screenci/.record.lock', {
|
||||
force: true,
|
||||
})
|
||||
|
||||
await lock.release()
|
||||
})
|
||||
|
||||
it('reclaims a stale lock when it exceeds the max age', async () => {
|
||||
const mockWriteFile = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('exists'), { code: 'EEXIST' })
|
||||
)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const mockRm = vi.fn().mockResolvedValue(undefined)
|
||||
const { acquireRecordRunLock } = await import('./cli')
|
||||
|
||||
const lock = await acquireRecordRunLock(
|
||||
'/repo/.screenci',
|
||||
'Test Project',
|
||||
{
|
||||
pid: 123,
|
||||
clock: () => new Date('2026-07-05T20:05:00.000Z'),
|
||||
isPidAlive: () => true,
|
||||
fs: {
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
readFile: vi.fn().mockResolvedValue(
|
||||
JSON.stringify({
|
||||
pid: 456,
|
||||
startedAt: '2026-07-05T10:00:00.000Z',
|
||||
projectName: 'Other Project',
|
||||
})
|
||||
),
|
||||
writeFile: mockWriteFile,
|
||||
rm: mockRm,
|
||||
},
|
||||
addSignalListener: vi.fn(),
|
||||
removeSignalListener: vi.fn(),
|
||||
removeLockSync: vi.fn(),
|
||||
}
|
||||
)
|
||||
|
||||
expect(mockRm).toHaveBeenCalledWith('/repo/.screenci/.record.lock', {
|
||||
force: true,
|
||||
})
|
||||
|
||||
await lock.release()
|
||||
})
|
||||
})
|
||||
|
||||
describe('record command', () => {
|
||||
beforeEach(() => {
|
||||
process.env.SCREENCI_SECRET = 'test-secret'
|
||||
@@ -502,6 +630,99 @@ describe('CLI', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('fails the run without printing a results URL when only an unrelated sibling recording is uploadable', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'cli.js',
|
||||
'record',
|
||||
'--config',
|
||||
'test-fixtures/record-upload.config.ts',
|
||||
'--grep',
|
||||
'code cut',
|
||||
]
|
||||
mockReadFile.mockImplementation(async (path: string | URL) => {
|
||||
const pathString = String(path)
|
||||
if (pathString.endsWith('record-upload.config.ts')) {
|
||||
return "export default { projectName: 'Test Project' }"
|
||||
}
|
||||
if (pathString.endsWith('/code-cut/data.json')) {
|
||||
return JSON.stringify({
|
||||
events: [],
|
||||
metadata: { videoName: 'Code Cut' },
|
||||
})
|
||||
}
|
||||
if (pathString.endsWith('/styled-backgrounds/data.json')) {
|
||||
return JSON.stringify({
|
||||
events: [],
|
||||
metadata: { videoName: 'Styled Backgrounds' },
|
||||
})
|
||||
}
|
||||
if (pathString.endsWith('package.json')) {
|
||||
return JSON.stringify({ version: '0.0.32' })
|
||||
}
|
||||
return ''
|
||||
})
|
||||
mockReaddir.mockResolvedValue(['code-cut', 'styled-backgrounds'])
|
||||
mockExistsSync.mockImplementation((path: string) => {
|
||||
if (path.endsWith('test-fixtures/record-upload.config.ts')) return true
|
||||
if (path.endsWith('/code-cut/data.json')) return true
|
||||
if (path.endsWith('/styled-backgrounds/data.json')) return true
|
||||
if (path.endsWith('/styled-backgrounds/recording.mp4')) return true
|
||||
return false
|
||||
})
|
||||
mockSpawn.mockImplementation((_command: string, args: string[]) => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
unref: vi.fn(),
|
||||
stdout: new EventEmitter(),
|
||||
stderr: new EventEmitter(),
|
||||
}) as unknown as ChildProcess & {
|
||||
stdout: EventEmitter
|
||||
stderr: EventEmitter
|
||||
}
|
||||
|
||||
process.nextTick(() => {
|
||||
if (args.includes('--list')) {
|
||||
child.stdout.emit(
|
||||
'data',
|
||||
JSON.stringify({
|
||||
suites: [{ specs: [{ title: 'Code Cut' }] }],
|
||||
})
|
||||
)
|
||||
}
|
||||
child.emit('close', 0)
|
||||
})
|
||||
|
||||
return child
|
||||
})
|
||||
|
||||
const { main } = await import('./cli')
|
||||
|
||||
await expect(main()).rejects.toThrow(
|
||||
'Not all recordings succeeded to upload.'
|
||||
)
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('/cli/upload/start'),
|
||||
expect.objectContaining({
|
||||
body: expect.stringContaining('Styled Backgrounds'),
|
||||
})
|
||||
)
|
||||
expect(
|
||||
mockWriteFile.mock.calls.some(
|
||||
([path]) =>
|
||||
typeof path === 'string' && path.endsWith('last-record.json')
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
loggerInfoSpy.mock.calls.some((call) =>
|
||||
String(call[0]).includes('Results available at:')
|
||||
)
|
||||
).toBe(false)
|
||||
expect(loggerWarnSpy).toHaveBeenCalledWith(
|
||||
'Code Cut: Missing recording.mp4 for "Code Cut"'
|
||||
)
|
||||
})
|
||||
|
||||
it('injects Studio text overrides into the recording env', async () => {
|
||||
process.argv = ['node', 'cli.js', 'record']
|
||||
const overrides = { en: { heading: 'From Studio' } }
|
||||
@@ -683,6 +904,7 @@ describe('CLI', () => {
|
||||
projectId: 'project_123',
|
||||
recordId: expect.any(String),
|
||||
hadFailures: false,
|
||||
uploadedVideoNames: expect.any(Array),
|
||||
studioNotices: [],
|
||||
elevenLabsKeyMissingVideos: [],
|
||||
notices: [],
|
||||
@@ -1474,6 +1696,7 @@ describe('CLI', () => {
|
||||
projectId: null,
|
||||
recordId: null,
|
||||
hadFailures: false,
|
||||
uploadedVideoNames: expect.any(Array),
|
||||
studioNotices: [],
|
||||
elevenLabsKeyMissingVideos: [],
|
||||
notices: [],
|
||||
@@ -1513,6 +1736,7 @@ describe('CLI', () => {
|
||||
projectId: null,
|
||||
recordId: expect.any(String),
|
||||
hadFailures: true,
|
||||
uploadedVideoNames: expect.any(Array),
|
||||
studioNotices: [],
|
||||
elevenLabsKeyMissingVideos: [],
|
||||
notices: [],
|
||||
@@ -1607,6 +1831,7 @@ describe('CLI', () => {
|
||||
projectId: 'project_123',
|
||||
recordId: expect.any(String),
|
||||
hadFailures: false,
|
||||
uploadedVideoNames: expect.any(Array),
|
||||
studioNotices: [],
|
||||
elevenLabsKeyMissingVideos: [],
|
||||
notices: [],
|
||||
@@ -1712,6 +1937,7 @@ describe('CLI', () => {
|
||||
projectId: 'project_123',
|
||||
recordId: expect.any(String),
|
||||
hadFailures: true,
|
||||
uploadedVideoNames: expect.any(Array),
|
||||
studioNotices: [],
|
||||
elevenLabsKeyMissingVideos: [],
|
||||
notices: [],
|
||||
@@ -1921,6 +2147,7 @@ describe('CLI', () => {
|
||||
projectId: 'project_123',
|
||||
recordId: expect.any(String),
|
||||
hadFailures: false,
|
||||
uploadedVideoNames: expect.any(Array),
|
||||
studioNotices: [],
|
||||
elevenLabsKeyMissingVideos: [],
|
||||
notices: [],
|
||||
@@ -2130,6 +2357,7 @@ describe('CLI', () => {
|
||||
projectId: 'project_123',
|
||||
recordId: expect.any(String),
|
||||
hadFailures: true,
|
||||
uploadedVideoNames: expect.any(Array),
|
||||
studioNotices: [],
|
||||
elevenLabsKeyMissingVideos: [],
|
||||
notices: [],
|
||||
@@ -2144,6 +2372,166 @@ describe('CLI', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects the whole anonymous upload when the per-recording trial cap is exceeded', async () => {
|
||||
mockReaddir.mockResolvedValue([
|
||||
'demo-video',
|
||||
'second-video',
|
||||
'third-video',
|
||||
'fourth-video',
|
||||
])
|
||||
mockReadFile.mockImplementation(async (path: string | URL) => {
|
||||
const pathString = String(path)
|
||||
if (pathString.endsWith('package.json')) {
|
||||
return JSON.stringify({ version: '0.0.32' })
|
||||
}
|
||||
if (pathString.endsWith('data.json')) {
|
||||
const videoName = pathString.includes('/second-video/')
|
||||
? 'Second Demo'
|
||||
: pathString.includes('/third-video/')
|
||||
? 'Third Demo'
|
||||
: pathString.includes('/fourth-video/')
|
||||
? 'Fourth Demo'
|
||||
: 'Demo'
|
||||
return JSON.stringify({ events: [], metadata: { videoName } })
|
||||
}
|
||||
return ''
|
||||
})
|
||||
mockExistsSync.mockImplementation(
|
||||
(path: string) =>
|
||||
path.endsWith('data.json') || path.endsWith('recording.mp4')
|
||||
)
|
||||
|
||||
const { uploadRecordings, anonCredential } = await import('./cli')
|
||||
|
||||
const result = await uploadRecordings(
|
||||
'/repo/.screenci',
|
||||
'Test Project',
|
||||
'https://api.screenci.test',
|
||||
anonCredential('anon-token')
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
projectId: null,
|
||||
recordId: null,
|
||||
hadFailures: true,
|
||||
uploadedVideoNames: expect.any(Array),
|
||||
studioNotices: [],
|
||||
elevenLabsKeyMissingVideos: [],
|
||||
notices: [],
|
||||
failedVideoNames: ['Demo', 'Second Demo', 'Third Demo', 'Fourth Demo'],
|
||||
failedVideoMessages: [
|
||||
{
|
||||
videoName: 'Demo',
|
||||
message:
|
||||
'Anonymous trials are capped at 3 videos/screenshots per recording. Split this into smaller runs or sign up to record more in one run.',
|
||||
},
|
||||
{
|
||||
videoName: 'Second Demo',
|
||||
message:
|
||||
'Anonymous trials are capped at 3 videos/screenshots per recording. Split this into smaller runs or sign up to record more in one run.',
|
||||
},
|
||||
{
|
||||
videoName: 'Third Demo',
|
||||
message:
|
||||
'Anonymous trials are capped at 3 videos/screenshots per recording. Split this into smaller runs or sign up to record more in one run.',
|
||||
},
|
||||
{
|
||||
videoName: 'Fourth Demo',
|
||||
message:
|
||||
'Anonymous trials are capped at 3 videos/screenshots per recording. Split this into smaller runs or sign up to record more in one run.',
|
||||
},
|
||||
],
|
||||
plan: null,
|
||||
})
|
||||
expect(mockFetch).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('/cli/upload/start'),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('prints one shared warning when every anonymous failure has the same cap message', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'cli.js',
|
||||
'record',
|
||||
'--config',
|
||||
'test-fixtures/record-upload.config.ts',
|
||||
]
|
||||
delete process.env.SCREENCI_SECRET
|
||||
mockReaddir.mockResolvedValue([
|
||||
'ru-video',
|
||||
'where-video',
|
||||
'en-video',
|
||||
'fi-video',
|
||||
])
|
||||
mockReadFile.mockImplementation(async (path: string | URL) => {
|
||||
const pathString = String(path)
|
||||
if (pathString.endsWith('package.json')) {
|
||||
return JSON.stringify({ version: '0.0.32' })
|
||||
}
|
||||
if (pathString.endsWith('record-upload.config.ts')) {
|
||||
return "export default { projectName: 'Test Project' }"
|
||||
}
|
||||
if (pathString.endsWith('data.json')) {
|
||||
const metadata = pathString.includes('/ru-video/')
|
||||
? { videoName: 'How to find docs', languages: ['ru'] }
|
||||
: pathString.includes('/en-video/')
|
||||
? { videoName: 'How to find docs', languages: ['en'] }
|
||||
: pathString.includes('/fi-video/')
|
||||
? { videoName: 'How to find docs', languages: ['fi'] }
|
||||
: { videoName: 'Where to find docs' }
|
||||
return JSON.stringify({ events: [], metadata })
|
||||
}
|
||||
return ''
|
||||
})
|
||||
mockExistsSync.mockImplementation(
|
||||
(path: string) =>
|
||||
path.endsWith('test-fixtures/record-upload.config.ts') ||
|
||||
path.endsWith('data.json') ||
|
||||
path.endsWith('recording.mp4')
|
||||
)
|
||||
mockFetch.mockImplementation(async (input: string | URL) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/cli/anon-session-status')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
status: 'pending',
|
||||
remaining: 3,
|
||||
}),
|
||||
text: vi.fn().mockResolvedValue(''),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
text: vi.fn().mockResolvedValue(''),
|
||||
}
|
||||
})
|
||||
mockSpawn.mockImplementation(() => {
|
||||
process.nextTick(() => mockChildProcess.emit('close', 0))
|
||||
return mockChildProcess as unknown as ChildProcess
|
||||
})
|
||||
|
||||
const { main } = await import('./cli')
|
||||
|
||||
await expect(main()).rejects.toThrow(
|
||||
'Not all recordings succeeded to upload.'
|
||||
)
|
||||
|
||||
const anonCapWarning =
|
||||
'Anonymous trials are capped at 3 videos/screenshots per recording. Split this into smaller runs or sign up to record more in one run.'
|
||||
expect(
|
||||
loggerWarnSpy.mock.calls.filter((call) => call[0] === anonCapWarning)
|
||||
).toHaveLength(1)
|
||||
expect(loggerWarnSpy).toHaveBeenCalledWith(
|
||||
"Not all recordings succeeded to upload. Failed videos: 'How to find docs [ru]', 'Where to find docs', 'How to find docs [en]', 'How to find docs [fi]'. Some videos may be missing from the project."
|
||||
)
|
||||
})
|
||||
|
||||
it('removes uploaded recording directories after successful upload', async () => {
|
||||
mockReaddir.mockResolvedValue(['demo-video'])
|
||||
mockReadFile.mockImplementation(async (path: string | URL) => {
|
||||
@@ -2204,6 +2592,7 @@ describe('CLI', () => {
|
||||
projectId: 'project_123',
|
||||
recordId: expect.any(String),
|
||||
hadFailures: false,
|
||||
uploadedVideoNames: expect.any(Array),
|
||||
studioNotices: [],
|
||||
elevenLabsKeyMissingVideos: [],
|
||||
notices: [],
|
||||
@@ -2278,6 +2667,7 @@ describe('CLI', () => {
|
||||
projectId: 'project_123',
|
||||
recordId: expect.any(String),
|
||||
hadFailures: false,
|
||||
uploadedVideoNames: expect.any(Array),
|
||||
studioNotices: [],
|
||||
elevenLabsKeyMissingVideos: [],
|
||||
notices: [],
|
||||
@@ -2383,6 +2773,7 @@ describe('CLI', () => {
|
||||
projectId: 'project_123',
|
||||
recordId: expect.any(String),
|
||||
hadFailures: false,
|
||||
uploadedVideoNames: expect.any(Array),
|
||||
studioNotices: [],
|
||||
elevenLabsKeyMissingVideos: [],
|
||||
notices: [],
|
||||
@@ -2732,23 +3123,21 @@ describe('CLI', () => {
|
||||
'Failed Demo: Upload limit reached for current plan.'
|
||||
)
|
||||
expect(loggerWarnSpy).toHaveBeenCalledWith(
|
||||
'Not all recordings succeeded to upload. Failed videos: Failed Demo. Some videos may be missing from the project.'
|
||||
"Not all recordings succeeded to upload. Failed videos: 'Failed Demo'. Some videos may be missing from the project."
|
||||
)
|
||||
|
||||
// Failure warnings (stderr) must be emitted before the "Results available
|
||||
// at:" line (stdout) so the URL stays directly under its message: in
|
||||
// non-TTY CI logs stdout is buffered while stderr flushes immediately, so
|
||||
// warnings logged afterwards would otherwise split the message from its URL.
|
||||
const lastWarnOrder = Math.max(...loggerWarnSpy.mock.invocationCallOrder)
|
||||
const resultsInfoCall = loggerInfoSpy.mock.calls.findIndex((call) =>
|
||||
stripVTControlCharacters(String(call[0])).includes(
|
||||
'Results available at:'
|
||||
)
|
||||
)
|
||||
expect(resultsInfoCall).toBeGreaterThanOrEqual(0)
|
||||
expect(resultsInfoCall).toBe(-1)
|
||||
expect(
|
||||
loggerInfoSpy.mock.invocationCallOrder[resultsInfoCall]
|
||||
).toBeGreaterThan(lastWarnOrder)
|
||||
mockWriteFile.mock.calls.some(
|
||||
([path]) =>
|
||||
typeof path === 'string' && path.endsWith('last-record.json')
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('formats expressive narration tier failures with a fix suggestion', async () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ const mockCreateReadStream = vi.fn()
|
||||
const mockAppendFile = vi.fn()
|
||||
const mockWriteFile = vi.fn()
|
||||
const mockMkdir = vi.fn()
|
||||
const mockRm = vi.fn()
|
||||
const mockInput = vi.fn()
|
||||
const mockConfirm = vi.fn()
|
||||
const mockCreateHttpServer = vi.fn()
|
||||
@@ -188,6 +189,7 @@ vi.mock('fs', () => ({
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
appendFile: mockAppendFile,
|
||||
rm: mockRm,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
@@ -195,6 +197,7 @@ vi.mock('fs/promises', () => ({
|
||||
mkdir: mockMkdir,
|
||||
default: {
|
||||
appendFile: mockAppendFile,
|
||||
rm: mockRm,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
@@ -237,6 +240,7 @@ describe('CLI', () => {
|
||||
mockAppendFile.mockResolvedValue(undefined)
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
mockMkdir.mockResolvedValue(undefined)
|
||||
mockRm.mockResolvedValue(undefined)
|
||||
mockReaddir.mockResolvedValue([])
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
if (process.env.VITE_APP_BASE_URL === undefined) {
|
||||
|
||||
@@ -11,7 +11,15 @@ import {
|
||||
} from 'fs'
|
||||
import { createHash, randomUUID } from 'crypto'
|
||||
import { createRequire } from 'module'
|
||||
import { appendFile, readdir, readFile, stat, writeFile } from 'fs/promises'
|
||||
import {
|
||||
appendFile,
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from 'fs/promises'
|
||||
import {
|
||||
delimiter,
|
||||
dirname,
|
||||
@@ -62,7 +70,9 @@ import { OVERLAY_CACHE_DIR_NAME } from './src/htmlRasterizer.js'
|
||||
import { maybeExtractVoiceSampleAudio } from './src/voiceSampleAudio.js'
|
||||
import {
|
||||
type CliCredential,
|
||||
ANON_MAX_VIDEOS_PER_RECORDING,
|
||||
ANON_SESSION_FILE,
|
||||
ANON_TOKEN_HEADER,
|
||||
anonCredential,
|
||||
checkAnonSessionStatus,
|
||||
deleteAnonSessionFile,
|
||||
@@ -91,6 +101,8 @@ const SCREENCI_RECORD_DOCS_URL =
|
||||
// Records the recordId of the most recent `screenci record` upload so
|
||||
// `screenci info` can report exactly the run that was just made.
|
||||
const SCREENCI_LAST_RECORD_FILE = 'last-record.json'
|
||||
const SCREENCI_RECORD_LOCK_FILE = '.record.lock'
|
||||
const SCREENCI_RECORD_LOCK_MAX_AGE_MS = 6 * 60 * 60 * 1000
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
type PlaywrightListReportSuite = {
|
||||
@@ -107,6 +119,30 @@ type PlaywrightListReport = {
|
||||
}>
|
||||
}
|
||||
|
||||
type RecordRunLock = {
|
||||
pid: number
|
||||
startedAt: string
|
||||
projectName: string
|
||||
}
|
||||
|
||||
type RecordRunLockFs = {
|
||||
mkdir: typeof mkdir
|
||||
readFile: typeof readFile
|
||||
writeFile: typeof writeFile
|
||||
rm: typeof rm
|
||||
}
|
||||
|
||||
type AcquireRecordRunLockDeps = {
|
||||
fs: RecordRunLockFs
|
||||
clock: () => Date
|
||||
isPidAlive: (pid: number) => boolean
|
||||
pid: number
|
||||
addSignalListener: typeof process.on
|
||||
removeSignalListener: typeof process.off
|
||||
removeLockSync: (lockPath: string) => void
|
||||
maxAgeMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether the current session can complete an interactive browser
|
||||
* sign-in. A session is interactive only when both stdin and stdout are
|
||||
@@ -405,6 +441,7 @@ type PreparedUploadAsset = {
|
||||
type UploadCandidate = {
|
||||
entry: string
|
||||
videoName: string
|
||||
displayVideoName: string
|
||||
data: RecordingData
|
||||
preparedUploadAssets: PreparedUploadAsset[]
|
||||
}
|
||||
@@ -602,11 +639,42 @@ async function loadUploadCandidate(
|
||||
return {
|
||||
entry,
|
||||
videoName,
|
||||
displayVideoName: videoName,
|
||||
data,
|
||||
preparedUploadAssets,
|
||||
}
|
||||
}
|
||||
|
||||
function disambiguateUploadCandidateDisplayNames(
|
||||
candidates: UploadCandidate[]
|
||||
): UploadCandidate[] {
|
||||
const counts = new Map<string, number>()
|
||||
for (const candidate of candidates) {
|
||||
counts.set(candidate.videoName, (counts.get(candidate.videoName) ?? 0) + 1)
|
||||
}
|
||||
|
||||
return candidates.map((candidate) => {
|
||||
if ((counts.get(candidate.videoName) ?? 0) <= 1) return candidate
|
||||
|
||||
const languages = candidate.data.metadata?.languages
|
||||
if (Array.isArray(languages) && languages.length === 1) {
|
||||
const [language] = languages
|
||||
if (typeof language === 'string' && language.length > 0) {
|
||||
return {
|
||||
...candidate,
|
||||
displayVideoName: `${candidate.videoName} [${language}]`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate.entry !== candidate.videoName) {
|
||||
return { ...candidate, displayVideoName: candidate.entry }
|
||||
}
|
||||
|
||||
return candidate
|
||||
})
|
||||
}
|
||||
|
||||
async function uploadRecordingCandidate(
|
||||
candidate: UploadCandidate,
|
||||
screenciDir: string,
|
||||
@@ -623,7 +691,13 @@ async function uploadRecordingCandidate(
|
||||
recordId: string,
|
||||
expectedScreenshotCount: number
|
||||
): Promise<UploadJobResult> {
|
||||
const { entry, videoName, data: rawData, preparedUploadAssets } = candidate
|
||||
const {
|
||||
entry,
|
||||
videoName,
|
||||
displayVideoName,
|
||||
data: rawData,
|
||||
preparedUploadAssets,
|
||||
} = candidate
|
||||
let projectId: string | null = null
|
||||
let videoId: string | null = null
|
||||
let plan: OrgPlan | null = null
|
||||
@@ -645,8 +719,8 @@ async function uploadRecordingCandidate(
|
||||
projectId: null,
|
||||
videoId: null,
|
||||
hadFailure: true,
|
||||
videoName,
|
||||
failureMessage: `Missing ${recordingFileName} for "${videoName}"`,
|
||||
videoName: displayVideoName,
|
||||
failureMessage: `Missing ${recordingFileName} for "${displayVideoName}"`,
|
||||
recordId,
|
||||
}
|
||||
}
|
||||
@@ -670,8 +744,11 @@ async function uploadRecordingCandidate(
|
||||
projectId: null,
|
||||
videoId: null,
|
||||
hadFailure: true,
|
||||
videoName,
|
||||
failureMessage: formatUnresolvedAssetMessage(videoName, unresolved),
|
||||
videoName: displayVideoName,
|
||||
failureMessage: formatUnresolvedAssetMessage(
|
||||
displayVideoName,
|
||||
unresolved
|
||||
),
|
||||
recordId,
|
||||
}
|
||||
}
|
||||
@@ -725,7 +802,7 @@ async function uploadRecordingCandidate(
|
||||
videoId: null,
|
||||
hadFailure: true,
|
||||
elevenLabsKeyMissing: true,
|
||||
videoName,
|
||||
videoName: displayVideoName,
|
||||
recordId,
|
||||
}
|
||||
}
|
||||
@@ -733,9 +810,9 @@ async function uploadRecordingCandidate(
|
||||
projectId: null,
|
||||
videoId: null,
|
||||
hadFailure: true,
|
||||
videoName,
|
||||
videoName: displayVideoName,
|
||||
failureMessage: formatUploadStartFailureMessage(
|
||||
videoName,
|
||||
displayVideoName,
|
||||
startResponse.status,
|
||||
text,
|
||||
credential.value
|
||||
@@ -769,7 +846,7 @@ async function uploadRecordingCandidate(
|
||||
if (startBody.dependencyErrors && startBody.dependencyErrors.length > 0) {
|
||||
for (const depError of startBody.dependencyErrors) {
|
||||
logger.error(
|
||||
`Render dependency error in "${videoName}": ${depError.detail}. This render will fail until it is fixed.`
|
||||
`Render dependency error in "${displayVideoName}": ${depError.detail}. This render will fail until it is fixed.`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -834,8 +911,8 @@ async function uploadRecordingCandidate(
|
||||
projectId,
|
||||
videoId,
|
||||
hadFailure: true,
|
||||
videoName,
|
||||
failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${extractBackendError(text)}${hint401(recordingResponse.status, credential.value)}`,
|
||||
videoName: displayVideoName,
|
||||
failureMessage: `Failed to upload recording for "${displayVideoName}": ${recordingResponse.status} ${extractBackendError(text)}${hint401(recordingResponse.status, credential.value)}`,
|
||||
recordId,
|
||||
...(plan !== null && { plan }),
|
||||
}
|
||||
@@ -847,7 +924,7 @@ async function uploadRecordingCandidate(
|
||||
projectId,
|
||||
videoId,
|
||||
hadFailure: false,
|
||||
videoName,
|
||||
videoName: displayVideoName,
|
||||
recordId,
|
||||
...(studio !== undefined && { studio }),
|
||||
...(plan !== null && { plan }),
|
||||
@@ -870,7 +947,7 @@ async function uploadRecordingCandidate(
|
||||
projectId,
|
||||
videoId,
|
||||
hadFailure: true,
|
||||
videoName,
|
||||
videoName: displayVideoName,
|
||||
failureMessage: err instanceof Error ? err.message : String(err),
|
||||
recordId,
|
||||
...(plan !== null && { plan }),
|
||||
@@ -882,8 +959,8 @@ async function uploadRecordingCandidate(
|
||||
projectId,
|
||||
videoId,
|
||||
hadFailure: true,
|
||||
videoName,
|
||||
failureMessage: `Network error uploading "${videoName}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
videoName: displayVideoName,
|
||||
failureMessage: `Network error uploading "${displayVideoName}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
recordId,
|
||||
...(plan !== null && { plan }),
|
||||
}
|
||||
@@ -1830,6 +1907,37 @@ export function formatFailedVideoMessage(
|
||||
return `${videoName}: ${message}`
|
||||
}
|
||||
|
||||
function quoteFailedVideoName(videoName: string): string {
|
||||
return `'${videoName.replaceAll("'", "\\'")}'`
|
||||
}
|
||||
|
||||
export function formatFailedVideoNamesSummary(videoNames: string[]): string {
|
||||
if (videoNames.length === 0) return 'unknown'
|
||||
return videoNames
|
||||
.map((videoName) => quoteFailedVideoName(videoName))
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
export function collapseFailedVideoWarnings(
|
||||
failures: Array<{ videoName: string; message: string }>
|
||||
): string[] {
|
||||
const byMessage = new Map<string, string[]>()
|
||||
|
||||
for (const failure of failures) {
|
||||
const names = byMessage.get(failure.message)
|
||||
if (names) names.push(failure.videoName)
|
||||
else byMessage.set(failure.message, [failure.videoName])
|
||||
}
|
||||
|
||||
return [...byMessage.entries()].map(([message, videoNames]) => {
|
||||
if (videoNames.length === 1) {
|
||||
return formatFailedVideoMessage(videoNames[0] ?? 'unknown', message)
|
||||
}
|
||||
|
||||
return message
|
||||
})
|
||||
}
|
||||
|
||||
export function printUploadStartFailureMessage(
|
||||
videoName: string,
|
||||
status: number,
|
||||
@@ -2106,11 +2214,13 @@ export async function uploadRecordings(
|
||||
apiUrl: string,
|
||||
credential: CliCredential,
|
||||
specificEntry?: string,
|
||||
verbose = false
|
||||
verbose = false,
|
||||
allowedVideoNames?: readonly string[]
|
||||
): Promise<{
|
||||
projectId: string | null
|
||||
recordId: string | null
|
||||
hadFailures: boolean
|
||||
uploadedVideoNames: string[]
|
||||
failedVideoNames: string[]
|
||||
failedVideoMessages: Array<{ videoName: string; message: string }>
|
||||
studioNotices: StudioUploadNotice[]
|
||||
@@ -2129,6 +2239,7 @@ export async function uploadRecordings(
|
||||
projectId: null,
|
||||
recordId: null,
|
||||
hadFailures: false,
|
||||
uploadedVideoNames: [],
|
||||
failedVideoNames: [],
|
||||
failedVideoMessages: [],
|
||||
studioNotices: [],
|
||||
@@ -2145,22 +2256,69 @@ export async function uploadRecordings(
|
||||
let firstProjectId: string | null = null
|
||||
|
||||
try {
|
||||
const candidates = (
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
uploadAbort.throwIfAborted()
|
||||
return await loadUploadCandidate(screenciDir, entry, verbose)
|
||||
})
|
||||
)
|
||||
).filter((candidate): candidate is UploadCandidate => candidate !== null)
|
||||
const candidates = disambiguateUploadCandidateDisplayNames(
|
||||
(
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
uploadAbort.throwIfAborted()
|
||||
return await loadUploadCandidate(screenciDir, entry, verbose)
|
||||
})
|
||||
)
|
||||
).filter((candidate): candidate is UploadCandidate => candidate !== null)
|
||||
)
|
||||
const requestedVideoNames =
|
||||
allowedVideoNames !== undefined ? new Set(allowedVideoNames) : null
|
||||
const filteredCandidates =
|
||||
requestedVideoNames === null
|
||||
? candidates
|
||||
: candidates.filter((candidate) =>
|
||||
requestedVideoNames.has(candidate.videoName)
|
||||
)
|
||||
|
||||
if (candidates.length === 0) {
|
||||
if (filteredCandidates.length === 0) {
|
||||
const missingRequestedVideoNames =
|
||||
requestedVideoNames === null
|
||||
? []
|
||||
: [...requestedVideoNames].filter(
|
||||
(videoName) =>
|
||||
!candidates.some(
|
||||
(candidate) => candidate.videoName === videoName
|
||||
)
|
||||
)
|
||||
return {
|
||||
projectId: null,
|
||||
recordId: null,
|
||||
hadFailures: false,
|
||||
failedVideoNames: [],
|
||||
failedVideoMessages: [],
|
||||
hadFailures: missingRequestedVideoNames.length > 0,
|
||||
uploadedVideoNames: [],
|
||||
failedVideoNames: missingRequestedVideoNames,
|
||||
failedVideoMessages: missingRequestedVideoNames.map((videoName) => ({
|
||||
videoName,
|
||||
message: `No recorded output found for "${videoName}"`,
|
||||
})),
|
||||
studioNotices: [],
|
||||
elevenLabsKeyMissingVideos: [],
|
||||
notices: [],
|
||||
plan: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
credential.header === ANON_TOKEN_HEADER &&
|
||||
filteredCandidates.length > ANON_MAX_VIDEOS_PER_RECORDING
|
||||
) {
|
||||
const failureMessage = `Anonymous trials are capped at ${ANON_MAX_VIDEOS_PER_RECORDING} videos/screenshots per recording. Split this into smaller runs or sign up to record more in one run.`
|
||||
return {
|
||||
projectId: null,
|
||||
recordId: null,
|
||||
hadFailures: true,
|
||||
uploadedVideoNames: [],
|
||||
failedVideoNames: filteredCandidates.map(
|
||||
(candidate) => candidate.displayVideoName
|
||||
),
|
||||
failedVideoMessages: filteredCandidates.map((candidate) => ({
|
||||
videoName: candidate.displayVideoName,
|
||||
message: failureMessage,
|
||||
})),
|
||||
studioNotices: [],
|
||||
elevenLabsKeyMissingVideos: [],
|
||||
notices: [],
|
||||
@@ -2169,19 +2327,19 @@ export async function uploadRecordings(
|
||||
}
|
||||
|
||||
const progressReporter = createUploadProgressReporter(
|
||||
candidates.map((candidate) => candidate.videoName),
|
||||
filteredCandidates.map((candidate) => candidate.displayVideoName),
|
||||
verbose
|
||||
)
|
||||
|
||||
// Screenshots from this run render together on one machine; the backend
|
||||
// waits for all of them to land before dispatching the batch, so it needs
|
||||
// to know how many to expect.
|
||||
const screenshotCount = candidates.filter(
|
||||
const screenshotCount = filteredCandidates.filter(
|
||||
(candidate) => candidate.data.output === 'screenshot'
|
||||
).length
|
||||
|
||||
const results = await Promise.all(
|
||||
candidates.map(
|
||||
filteredCandidates.map(
|
||||
async (candidate, index) =>
|
||||
await uploadRecordingCandidate(
|
||||
candidate,
|
||||
@@ -2204,6 +2362,9 @@ export async function uploadRecordings(
|
||||
const resolvedPlan =
|
||||
results.find((result) => result.plan !== undefined)?.plan ?? null
|
||||
const hadFailures = results.some((result) => result.hadFailure)
|
||||
const uploadedVideoNames = results
|
||||
.filter((result) => !result.hadFailure)
|
||||
.map((result) => result.videoName)
|
||||
const failedVideoNames = results
|
||||
.filter((result) => result.hadFailure)
|
||||
.map((result) => result.videoName)
|
||||
@@ -2237,6 +2398,7 @@ export async function uploadRecordings(
|
||||
projectId: firstProjectId,
|
||||
recordId,
|
||||
hadFailures,
|
||||
uploadedVideoNames,
|
||||
failedVideoNames,
|
||||
failedVideoMessages,
|
||||
studioNotices,
|
||||
@@ -2269,6 +2431,46 @@ async function writeGitHubProjectOutput(projectUrl: string): Promise<void> {
|
||||
await appendFile(githubOutput, `screenci_project_url=${projectUrl}\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* The builder titles each per-language Playwright test `${videoName} [${lang}]`
|
||||
* (src/builder.ts) so every language pass has a unique test title, while the
|
||||
* shared grouping key it writes to `metadata.videoName` carries NO language
|
||||
* suffix. `screenci record` discovers the videos to expect by their test titles,
|
||||
* but the uploader matches those against each recording's `metadata.videoName`.
|
||||
* Strip the trailing ` [<lang>]` so the requested name matches the recorded one;
|
||||
* otherwise a language-decorated title never matches and every upload reports
|
||||
* "No recorded output found". Only a language-code-shaped bracket is stripped, so
|
||||
* an unrelated trailing bracket in a video name is left intact.
|
||||
*/
|
||||
export function stripTestTitleLanguageSuffix(title: string): string {
|
||||
// Language codes are lowercase (ISO 639: en, es, zh, ...), with an optional
|
||||
// region subtag (pt-BR). Requiring lowercase avoids stripping unrelated
|
||||
// capitalized brackets like ` [New]`.
|
||||
return title.replace(/ \[[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})?\]$/, '')
|
||||
}
|
||||
|
||||
async function collectRequestedRecordVideoNames(
|
||||
configPath: string,
|
||||
additionalArgs: string[],
|
||||
languages: string | undefined
|
||||
): Promise<string[]> {
|
||||
const envForDiscovery = {
|
||||
...process.env,
|
||||
SCREENCI_CONFIG_DIR: dirname(configPath),
|
||||
SCREENCI_RECORDING: 'true',
|
||||
...(languages ? { [SCREENCI_LANGUAGES_ENV]: languages } : {}),
|
||||
}
|
||||
|
||||
const titles = await collectDiscoveredTestTitles(
|
||||
configPath,
|
||||
additionalArgs,
|
||||
envForDiscovery
|
||||
)
|
||||
// Requested names are matched against `metadata.videoName` (no language
|
||||
// suffix), so recover the videoName from each per-language test title.
|
||||
return [...new Set(titles.map(stripTestTitleLanguageSuffix))]
|
||||
}
|
||||
|
||||
async function loadScreenCIConfigAndEnv(configPath?: string): Promise<{
|
||||
resolvedConfigPath: string
|
||||
screenciConfig: ScreenCIConfig
|
||||
@@ -2738,6 +2940,171 @@ async function triggerRemoteRun(
|
||||
)
|
||||
}
|
||||
|
||||
function getRecordRunLockPath(screenciDir: string): string {
|
||||
return resolve(screenciDir, SCREENCI_RECORD_LOCK_FILE)
|
||||
}
|
||||
|
||||
function defaultIsPidAlive(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false
|
||||
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ESRCH') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function parseRecordRunLock(raw: string): RecordRunLock | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
pid?: unknown
|
||||
startedAt?: unknown
|
||||
projectName?: unknown
|
||||
}
|
||||
return typeof parsed.pid === 'number' &&
|
||||
Number.isInteger(parsed.pid) &&
|
||||
parsed.pid > 0 &&
|
||||
typeof parsed.startedAt === 'string' &&
|
||||
parsed.startedAt.length > 0 &&
|
||||
typeof parsed.projectName === 'string' &&
|
||||
parsed.projectName.length > 0
|
||||
? {
|
||||
pid: parsed.pid,
|
||||
startedAt: parsed.startedAt,
|
||||
projectName: parsed.projectName,
|
||||
}
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isRecordRunLockStale(
|
||||
lock: RecordRunLock,
|
||||
now: Date,
|
||||
isPidAlive: (pid: number) => boolean,
|
||||
maxAgeMs: number
|
||||
): boolean {
|
||||
const startedAtMs = Date.parse(lock.startedAt)
|
||||
if (Number.isNaN(startedAtMs)) return true
|
||||
if (now.getTime() - startedAtMs > maxAgeMs) return true
|
||||
return !isPidAlive(lock.pid)
|
||||
}
|
||||
|
||||
function formatRecordRunLockError(lock: RecordRunLock): string {
|
||||
return `Another 'screenci record' is in progress (pid ${lock.pid}, started ${lock.startedAt}, project "${lock.projectName}"). Wait for it or remove .screenci/.record.lock.`
|
||||
}
|
||||
|
||||
export async function acquireRecordRunLock(
|
||||
screenciDir: string,
|
||||
projectName: string,
|
||||
deps: Partial<AcquireRecordRunLockDeps> = {}
|
||||
): Promise<{ release: () => Promise<void> }> {
|
||||
const resolvedDeps: AcquireRecordRunLockDeps = {
|
||||
fs: deps.fs ?? { mkdir, readFile, writeFile, rm },
|
||||
clock: deps.clock ?? (() => new Date()),
|
||||
isPidAlive: deps.isPidAlive ?? defaultIsPidAlive,
|
||||
pid: deps.pid ?? process.pid,
|
||||
addSignalListener: deps.addSignalListener ?? process.on.bind(process),
|
||||
removeSignalListener:
|
||||
deps.removeSignalListener ?? process.off.bind(process),
|
||||
removeLockSync:
|
||||
deps.removeLockSync ??
|
||||
((lockPath) => {
|
||||
rmSync(lockPath, { force: true })
|
||||
}),
|
||||
maxAgeMs: deps.maxAgeMs ?? SCREENCI_RECORD_LOCK_MAX_AGE_MS,
|
||||
}
|
||||
const lockPath = getRecordRunLockPath(screenciDir)
|
||||
const lock: RecordRunLock = {
|
||||
pid: resolvedDeps.pid,
|
||||
startedAt: resolvedDeps.clock().toISOString(),
|
||||
projectName,
|
||||
}
|
||||
|
||||
await resolvedDeps.fs.mkdir(screenciDir, { recursive: true })
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
await resolvedDeps.fs.writeFile(
|
||||
lockPath,
|
||||
`${JSON.stringify(lock, null, 2)}\n`,
|
||||
{ flag: 'wx' }
|
||||
)
|
||||
break
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof Error) ||
|
||||
!('code' in error) ||
|
||||
error.code !== 'EEXIST'
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
|
||||
let existingLock: RecordRunLock | null = null
|
||||
try {
|
||||
existingLock = parseRecordRunLock(
|
||||
await resolvedDeps.fs.readFile(lockPath, 'utf-8')
|
||||
)
|
||||
} catch {
|
||||
existingLock = null
|
||||
}
|
||||
|
||||
if (
|
||||
existingLock !== null &&
|
||||
!isRecordRunLockStale(
|
||||
existingLock,
|
||||
resolvedDeps.clock(),
|
||||
resolvedDeps.isPidAlive,
|
||||
resolvedDeps.maxAgeMs
|
||||
)
|
||||
) {
|
||||
throw new Error(formatRecordRunLockError(existingLock))
|
||||
}
|
||||
|
||||
await resolvedDeps.fs.rm(lockPath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
let released = false
|
||||
|
||||
const removeLockSync = () => {
|
||||
if (released) return
|
||||
released = true
|
||||
resolvedDeps.removeSignalListener('SIGINT', handleSigint)
|
||||
resolvedDeps.removeSignalListener('SIGTERM', handleSigterm)
|
||||
try {
|
||||
resolvedDeps.removeLockSync(lockPath)
|
||||
} catch {
|
||||
// best-effort during signal shutdown
|
||||
}
|
||||
}
|
||||
|
||||
const release = async () => {
|
||||
if (released) return
|
||||
released = true
|
||||
resolvedDeps.removeSignalListener('SIGINT', handleSigint)
|
||||
resolvedDeps.removeSignalListener('SIGTERM', handleSigterm)
|
||||
try {
|
||||
await resolvedDeps.fs.rm(lockPath, { force: true })
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
const handleSigint = () => removeLockSync()
|
||||
const handleSigterm = () => removeLockSync()
|
||||
|
||||
resolvedDeps.addSignalListener('SIGINT', handleSigint)
|
||||
resolvedDeps.addSignalListener('SIGTERM', handleSigterm)
|
||||
|
||||
return { release }
|
||||
}
|
||||
|
||||
function getLastRecordFilePath(screenciDir: string): string {
|
||||
return resolve(screenciDir, SCREENCI_LAST_RECORD_FILE)
|
||||
}
|
||||
@@ -2907,7 +3274,8 @@ export async function ensureAnonRecordingAllowedOrExit(
|
||||
async function uploadRecordedVideosForConfig(
|
||||
configPath: string | undefined,
|
||||
playwrightFailure: Error | null,
|
||||
verbose: boolean
|
||||
verbose: boolean,
|
||||
requestedVideoNames?: readonly string[]
|
||||
): Promise<void> {
|
||||
// After recording, upload results to API if configured. `run` already
|
||||
// resolved the config (or exited), so this best-effort lookup only acts
|
||||
@@ -2959,6 +3327,7 @@ async function uploadRecordedVideosForConfig(
|
||||
projectId: string | null
|
||||
recordId: string | null
|
||||
hadFailures: boolean
|
||||
uploadedVideoNames: string[]
|
||||
failedVideoNames: string[]
|
||||
failedVideoMessages: Array<{ videoName: string; message: string }>
|
||||
studioNotices: StudioUploadNotice[]
|
||||
@@ -2969,6 +3338,7 @@ async function uploadRecordedVideosForConfig(
|
||||
projectId: null,
|
||||
recordId: null,
|
||||
hadFailures: false,
|
||||
uploadedVideoNames: [],
|
||||
failedVideoNames: [],
|
||||
failedVideoMessages: [],
|
||||
studioNotices: [],
|
||||
@@ -2983,7 +3353,8 @@ async function uploadRecordedVideosForConfig(
|
||||
apiUrl,
|
||||
credential,
|
||||
undefined,
|
||||
verbose
|
||||
verbose,
|
||||
requestedVideoNames
|
||||
)
|
||||
} catch (err) {
|
||||
if (isUploadCancelledError(err)) {
|
||||
@@ -2995,6 +3366,7 @@ async function uploadRecordedVideosForConfig(
|
||||
projectId,
|
||||
recordId,
|
||||
hadFailures,
|
||||
uploadedVideoNames,
|
||||
failedVideoNames,
|
||||
failedVideoMessages,
|
||||
studioNotices,
|
||||
@@ -3002,8 +3374,14 @@ async function uploadRecordedVideosForConfig(
|
||||
notices,
|
||||
plan,
|
||||
} = uploadResult
|
||||
const requestedUploadSucceeded =
|
||||
!hadFailures &&
|
||||
(requestedVideoNames === undefined ||
|
||||
requestedVideoNames.every((videoName) =>
|
||||
uploadedVideoNames.includes(videoName)
|
||||
))
|
||||
// Remember this run so `screenci info` can report exactly it.
|
||||
if (recordId !== null) {
|
||||
if (recordId !== null && requestedUploadSucceeded) {
|
||||
await saveLastRecordId(screenciDir, recordId)
|
||||
}
|
||||
// Emit upload-failure warnings (stderr) before the results block.
|
||||
@@ -3013,17 +3391,17 @@ async function uploadRecordedVideosForConfig(
|
||||
// it from its URL. Reporting failures first keeps the URL directly
|
||||
// under its message.
|
||||
if (hadFailures) {
|
||||
for (const failedVideo of failedVideoMessages) {
|
||||
logger.warn(
|
||||
formatFailedVideoMessage(failedVideo.videoName, failedVideo.message)
|
||||
)
|
||||
for (const warning of collapseFailedVideoWarnings(
|
||||
failedVideoMessages
|
||||
)) {
|
||||
logger.warn(warning)
|
||||
}
|
||||
logger.warn(
|
||||
`Not all recordings succeeded to upload. Failed videos: ${failedVideoNames.join(', ') || 'unknown'}. Some videos may be missing from the project.`
|
||||
`Not all recordings succeeded to upload. Failed videos: ${formatFailedVideoNamesSummary(failedVideoNames)}. Some videos may be missing from the project.`
|
||||
)
|
||||
}
|
||||
let resultUrl: string | null = null
|
||||
if (recordId !== null && projectId !== null) {
|
||||
if (requestedUploadSucceeded && recordId !== null && projectId !== null) {
|
||||
const recordUrl = `${appUrl}/record/${recordId}`
|
||||
resultUrl = recordUrl
|
||||
await writeGitHubProjectOutput(recordUrl)
|
||||
@@ -3034,7 +3412,7 @@ async function uploadRecordedVideosForConfig(
|
||||
: 'Recording finished, rendering in progress. Results available at:'
|
||||
)
|
||||
logger.info(pc.cyan(recordUrl))
|
||||
} else if (projectId !== null) {
|
||||
} else if (requestedUploadSucceeded && projectId !== null) {
|
||||
const projectUrl = `${appUrl}/project/${projectId}`
|
||||
resultUrl = projectUrl
|
||||
await writeGitHubProjectOutput(projectUrl)
|
||||
@@ -3165,48 +3543,75 @@ export async function main() {
|
||||
return
|
||||
}
|
||||
|
||||
let playwrightFailure: Error | null = null
|
||||
validateArgs(parsed.otherArgs)
|
||||
|
||||
// UPLOAD_EXISTING re-sends the recordings already on disk under `.screenci`
|
||||
// without re-running Playwright (resend the last local run when only the
|
||||
// upload failed). We skip the recording run and fall straight through to
|
||||
// the upload below, treating the on-disk recordings as the complete set.
|
||||
const uploadExisting = isUploadExistingEnabled()
|
||||
|
||||
if (!uploadExisting) {
|
||||
try {
|
||||
await run(
|
||||
'record',
|
||||
parsed.otherArgs,
|
||||
parsed.configPath,
|
||||
parsed.verbose,
|
||||
false,
|
||||
parsed.languages
|
||||
)
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) throw error
|
||||
if (error.message.startsWith('Playwright exited with code ')) {
|
||||
playwrightFailure = new RecordFailureHintError(error)
|
||||
} else {
|
||||
throw new RecordFailureHintError(error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
'UPLOAD_EXISTING set: skipping Playwright recording and re-uploading existing .screenci recordings.'
|
||||
)
|
||||
}
|
||||
|
||||
if (process.env.SCREENCI_RECORDING === 'true') return
|
||||
|
||||
await uploadRecordedVideosForConfig(
|
||||
parsed.configPath,
|
||||
playwrightFailure,
|
||||
parsed.verbose
|
||||
const resolvedConfigPath = resolveScreenCIConfigPathOrExit(
|
||||
parsed.configPath
|
||||
)
|
||||
await loadEnvFileFromConfigSource(resolvedConfigPath, false)
|
||||
const screenciConfig =
|
||||
await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath)
|
||||
const screenciDir = resolve(dirname(resolvedConfigPath), '.screenci')
|
||||
const requestedVideoNames =
|
||||
parsed.otherArgs.length > 0 || parsed.languages !== undefined
|
||||
? await collectRequestedRecordVideoNames(
|
||||
resolvedConfigPath,
|
||||
parsed.otherArgs,
|
||||
parsed.languages
|
||||
)
|
||||
: undefined
|
||||
const recordRunLock = await acquireRecordRunLock(
|
||||
screenciDir,
|
||||
screenciConfig.projectName
|
||||
)
|
||||
|
||||
if (playwrightFailure !== null) {
|
||||
throw playwrightFailure
|
||||
try {
|
||||
let playwrightFailure: Error | null = null
|
||||
|
||||
// UPLOAD_EXISTING re-sends the recordings already on disk under `.screenci`
|
||||
// without re-running Playwright (resend the last local run when only the
|
||||
// upload failed). We skip the recording run and fall straight through to
|
||||
// the upload below, treating the on-disk recordings as the complete set.
|
||||
const uploadExisting = isUploadExistingEnabled()
|
||||
|
||||
if (!uploadExisting) {
|
||||
try {
|
||||
await run(
|
||||
'record',
|
||||
parsed.otherArgs,
|
||||
parsed.configPath,
|
||||
parsed.verbose,
|
||||
false,
|
||||
parsed.languages
|
||||
)
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) throw error
|
||||
if (error.message.startsWith('Playwright exited with code ')) {
|
||||
playwrightFailure = new RecordFailureHintError(error)
|
||||
} else {
|
||||
throw new RecordFailureHintError(error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
'UPLOAD_EXISTING set: skipping Playwright recording and re-uploading existing .screenci recordings.'
|
||||
)
|
||||
}
|
||||
|
||||
if (process.env.SCREENCI_RECORDING === 'true') return
|
||||
|
||||
await uploadRecordedVideosForConfig(
|
||||
parsed.configPath,
|
||||
playwrightFailure,
|
||||
parsed.verbose,
|
||||
requestedVideoNames
|
||||
)
|
||||
|
||||
if (playwrightFailure !== null) {
|
||||
throw playwrightFailure
|
||||
}
|
||||
} finally {
|
||||
await recordRunLock.release()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const mockCreateReadStream = vi.fn()
|
||||
const mockAppendFile = vi.fn()
|
||||
const mockWriteFile = vi.fn()
|
||||
const mockMkdir = vi.fn()
|
||||
const mockRm = vi.fn()
|
||||
const mockInput = vi.fn()
|
||||
const mockConfirm = vi.fn()
|
||||
const mockCreateHttpServer = vi.fn()
|
||||
@@ -195,6 +196,7 @@ vi.mock('fs', () => ({
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
appendFile: mockAppendFile,
|
||||
rm: mockRm,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
@@ -202,6 +204,7 @@ vi.mock('fs/promises', () => ({
|
||||
mkdir: mockMkdir,
|
||||
default: {
|
||||
appendFile: mockAppendFile,
|
||||
rm: mockRm,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
@@ -244,6 +247,7 @@ describe('CLI', () => {
|
||||
mockAppendFile.mockResolvedValue(undefined)
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
mockMkdir.mockResolvedValue(undefined)
|
||||
mockRm.mockResolvedValue(undefined)
|
||||
mockReaddir.mockResolvedValue([])
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
if (process.env.VITE_APP_BASE_URL === undefined) {
|
||||
@@ -968,4 +972,32 @@ describe('CLI', () => {
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripTestTitleLanguageSuffix', () => {
|
||||
it('strips a trailing language suffix so titles match metadata.videoName', async () => {
|
||||
const { stripTestTitleLanguageSuffix } = await import('./cli')
|
||||
// Builder titles per-language tests `${videoName} [${lang}]`; the uploaded
|
||||
// recording is keyed by the unsuffixed videoName.
|
||||
expect(
|
||||
stripTestTitleLanguageSuffix('ScreenCI product pitch (code cut) [en]')
|
||||
).toBe('ScreenCI product pitch (code cut)')
|
||||
expect(stripTestTitleLanguageSuffix('Tour [es]')).toBe('Tour')
|
||||
expect(stripTestTitleLanguageSuffix('Tour [pt-BR]')).toBe('Tour')
|
||||
})
|
||||
|
||||
it('leaves an unsuffixed title and non-language brackets intact', async () => {
|
||||
const { stripTestTitleLanguageSuffix } = await import('./cli')
|
||||
expect(stripTestTitleLanguageSuffix('Tour')).toBe('Tour')
|
||||
// Only a trailing language-code-shaped bracket is stripped.
|
||||
expect(stripTestTitleLanguageSuffix('Dashboard [New]')).toBe(
|
||||
'Dashboard [New]'
|
||||
)
|
||||
expect(stripTestTitleLanguageSuffix('Report [v2]')).toBe('Report [v2]')
|
||||
// A video name that itself ends in `[en]` keeps that, dropping only the
|
||||
// builder-appended language suffix.
|
||||
expect(stripTestTitleLanguageSuffix('Dashboard [en] [en]')).toBe(
|
||||
'Dashboard [en]'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ const mockCreateReadStream = vi.fn()
|
||||
const mockAppendFile = vi.fn()
|
||||
const mockWriteFile = vi.fn()
|
||||
const mockMkdir = vi.fn()
|
||||
const mockRm = vi.fn()
|
||||
const mockInput = vi.fn()
|
||||
const mockConfirm = vi.fn()
|
||||
const mockCreateHttpServer = vi.fn()
|
||||
@@ -187,6 +188,7 @@ vi.mock('fs', () => ({
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
appendFile: mockAppendFile,
|
||||
rm: mockRm,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
@@ -194,6 +196,7 @@ vi.mock('fs/promises', () => ({
|
||||
mkdir: mockMkdir,
|
||||
default: {
|
||||
appendFile: mockAppendFile,
|
||||
rm: mockRm,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
@@ -236,6 +239,7 @@ describe('CLI', () => {
|
||||
mockAppendFile.mockResolvedValue(undefined)
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
mockMkdir.mockResolvedValue(undefined)
|
||||
mockRm.mockResolvedValue(undefined)
|
||||
mockReaddir.mockResolvedValue([])
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
if (process.env.VITE_APP_BASE_URL === undefined) {
|
||||
|
||||
+5
-4
@@ -280,10 +280,11 @@ Behavior:
|
||||
|
||||
- enables recording timing
|
||||
- writes local output into `.screenci/`
|
||||
- holds a per-project run lock at `.screenci/.record.lock` while recording and uploading. If another `screenci record` is already active, the second run exits non-zero instead of sharing the same `.screenci` output. Stale locks are reclaimed automatically when the pid is gone or the lock is older than the maximum run TTL
|
||||
- needs no `SCREENCI_SECRET` to run: without one, it uploads under a local, anonymous trial session and prints a link to view the result. Recording an anonymous trial agrees to the [Terms](https://screenci.com/legal/tos), which `record` prints before it starts. To upload straight to your organization instead, set `SCREENCI_SECRET` (from the project `.env`, the environment, or by running `init` with it) before recording
|
||||
- gives an anonymous trial exactly one recording. Once that first recording is used (or the trial expires), a second `screenci record` stops before it starts and prints a sign-up link instead of recording again, so no time is spent rendering a video that would only be refused. Sign up to keep recording; once you have, re-running `record` in the same folder links your account automatically and uploads normally. The trial previews expressive narration and up to 3 languages at once; see [Anonymous Trial](/docs/guides/anonymous-trial) for its full limits and what happens to that content after signup
|
||||
- uploads every successful recording, with or without `SCREENCI_SECRET`
|
||||
- prints a project URL after upload when rendering has been started remotely
|
||||
- uploads only the recordings requested by this run (for example the videos matched by `--grep` or `--languages`), with or without `SCREENCI_SECRET`
|
||||
- prints a results URL only when the requested recordings uploaded successfully. If a requested video is missing its `recording.mp4` or another upload failure occurs, `record` exits non-zero, does not save `.screenci/last-record.json`, and does not print a success URL for unrelated output
|
||||
- prints any informational notices returned by the service in cyan after upload (occasional, non-error status messages such as maintenance or timing notes). These never affect the exit code
|
||||
- new accounts start on the Free plan automatically (no plan selection step). Free renders include a ScreenCI watermark; paid plans remove it and raise render and active-video limits, and unlock multiple languages plus expressive tone control
|
||||
|
||||
@@ -353,8 +354,8 @@ Lists **every video in the project** with its public URLs as JSON, keyed by
|
||||
video name and language code. It is where you get the remote `videoId` for
|
||||
`make-public`, `make-private`, and `delete`.
|
||||
|
||||
When this machine has recorded a run, `info` also reports that run. Each
|
||||
`screenci record` upload stores its unique record id in
|
||||
When this machine has recorded a successful run, `info` also reports that run.
|
||||
Each `screenci record` upload stores its unique record id in
|
||||
`.screenci/last-record.json`; `info` then attaches, **only to the videos
|
||||
produced by that run**, a per-language `latestRecord` with the run's render
|
||||
status and its record-pinned URLs, plus a `latestRecordId` on the video. Videos
|
||||
|
||||
@@ -322,6 +322,11 @@ table, so any language below works with them regardless of `modelType`.
|
||||
(`values` and other non-narration features also work for every key regardless of
|
||||
model, since they carry no synthesized speech.)
|
||||
|
||||
One built-in-voice exception is worth calling out: Russian (`ru`) currently
|
||||
supports the built-in names `Ava`, `Daniel`, `Emma`, `Leo`, `Lily`, `Max`,
|
||||
`Miles`, and `Nora`. When a shared default also needs to cover Russian, use one
|
||||
of those names; the built-in fallback voice is `Ava`.
|
||||
|
||||
### Available with any model
|
||||
|
||||
These narrate with both the consistent (default) and expressive models:
|
||||
|
||||
+11
-2
@@ -22,6 +22,11 @@ The default voice is set once with `renderOptions.narration.voice`, in
|
||||
value) is the most specific override. Changing the voice re-renders without
|
||||
re-recording; changing the spoken text re-records.
|
||||
|
||||
Built-in voices are shared across supported narration languages with one current
|
||||
exception: for Russian (`ru`), the built-in choices are `Ava`, `Daniel`, `Emma`,
|
||||
`Leo`, `Lily`, `Max`, `Miles`, and `Nora`. The built-in fallback voice is `Ava`,
|
||||
so an unconfigured video stays valid there too.
|
||||
|
||||
`style` prompts require the Business tier, as does choosing `modelType:
|
||||
'expressive'` for a language that also has a consistent voice (a tone upgrade).
|
||||
A language whose only built-in voice is the expressive model uses it
|
||||
@@ -86,7 +91,7 @@ config or `video.use(...)` default.
|
||||
import { video, voices } from 'screenci'
|
||||
|
||||
// The default voice (how the narration is spoken).
|
||||
video.use({ renderOptions: { narration: { voice: { name: voices.Sophie } } } })
|
||||
video.use({ renderOptions: { narration: { voice: { name: voices.Ava } } } })
|
||||
|
||||
video.narration({
|
||||
en: {
|
||||
@@ -146,7 +151,7 @@ import { video, voices } from 'screenci'
|
||||
// language. Both are render options set with `use`.
|
||||
video.use({
|
||||
renderOptions: {
|
||||
narration: { corner: 'top-right', voice: { name: voices.Sophie } },
|
||||
narration: { corner: 'top-right', voice: { name: voices.Ava } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -322,6 +327,10 @@ to regenerate long blocks when only one line changes.
|
||||
ScreenCI ships with built-in voices that you can use across supported languages
|
||||
through the `voices` export.
|
||||
|
||||
Russian (`ru`) currently has a smaller built-in subset: `Ava`, `Daniel`, `Emma`,
|
||||
`Leo`, `Lily`, `Max`, `Miles`, and `Nora`. Use one of those names whenever a cue
|
||||
is spoken in Russian.
|
||||
|
||||
| Name | Gender | Character |
|
||||
| ---------- | ------ | ------------------------------ |
|
||||
| `Adrian` | Male | Clear, direct, and structured |
|
||||
|
||||
@@ -288,7 +288,7 @@ import { video, voices } from 'screenci'
|
||||
|
||||
// Voice is a render option (how narration is spoken).
|
||||
video.use({
|
||||
renderOptions: { narration: { voice: { name: voices.Sophie } } },
|
||||
renderOptions: { narration: { voice: { name: voices.Ava } } },
|
||||
})
|
||||
|
||||
// Localized narration cues by language.
|
||||
|
||||
@@ -3,7 +3,7 @@ import { autoZoom, hide, resetZoom, video, voices, zoomTo } from 'screenci'
|
||||
video.use({
|
||||
renderOptions: {
|
||||
narration: {
|
||||
voice: { name: voices.Sophie, style: 'Friendly product guide' },
|
||||
voice: { name: voices.Ava, style: 'Friendly product guide' },
|
||||
},
|
||||
output: {
|
||||
background: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { hide, resetZoom, video, voices, zoomTo } from 'screenci'
|
||||
video.use({
|
||||
renderOptions: {
|
||||
narration: {
|
||||
voice: { name: voices.Sophie, style: 'Friendly product guide' },
|
||||
voice: { name: voices.Ava, style: 'Friendly product guide' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { hide, resetZoom, video, voices, zoomTo } from 'screenci'
|
||||
video.use({
|
||||
renderOptions: {
|
||||
narration: {
|
||||
voice: { name: voices.Sophie, style: 'Friendly product guide' },
|
||||
voice: { name: voices.Ava, style: 'Friendly product guide' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { hide, resetZoom, video, voices, zoomTo } from 'screenci'
|
||||
video.use({
|
||||
renderOptions: {
|
||||
narration: {
|
||||
voice: { name: voices.Sophie, style: 'Friendly product guide' },
|
||||
voice: { name: voices.Ava, style: 'Friendly product guide' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { hide, resetZoom, video, voices, zoomTo } from 'screenci'
|
||||
video.use({
|
||||
renderOptions: {
|
||||
narration: {
|
||||
voice: { name: voices.Sophie, style: 'Friendly product guide' },
|
||||
voice: { name: voices.Ava, style: 'Friendly product guide' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ export const ANON_SESSION_FILE = 'anon-session.json'
|
||||
|
||||
export const ANON_TOKEN_HEADER = 'X-ScreenCI-Anon-Token'
|
||||
export const SECRET_HEADER = 'X-ScreenCI-Secret'
|
||||
export const ANON_MAX_VIDEOS_PER_RECORDING = 3
|
||||
|
||||
// Canonical Terms of Service URL. Hardcoded like the docs links elsewhere in
|
||||
// the CLI (the legal pages are not environment-specific). Anonymous trial
|
||||
|
||||
+5
-5
@@ -713,7 +713,7 @@ describe('createNarration', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults the top-level voice to Sophie when omitted', async () => {
|
||||
it('defaults the top-level voice to Ava when omitted', async () => {
|
||||
const cues = createNarration({
|
||||
en: {
|
||||
intro: 'Hello world',
|
||||
@@ -729,15 +729,15 @@ describe('createNarration', () => {
|
||||
'intro',
|
||||
undefined,
|
||||
{
|
||||
en: { text: 'Hello world', voice: voices.Sophie },
|
||||
es: { text: 'Hola mundo', voice: voices.Sophie },
|
||||
en: { text: 'Hello world', voice: voices.Ava },
|
||||
es: { text: 'Hola mundo', voice: voices.Ava },
|
||||
}
|
||||
)
|
||||
expect(recorder.registerVoiceForLang).toHaveBeenCalledWith('en', {
|
||||
name: 'Sophie',
|
||||
name: 'Ava',
|
||||
})
|
||||
expect(recorder.registerVoiceForLang).toHaveBeenCalledWith('es', {
|
||||
name: 'Sophie',
|
||||
name: 'Ava',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -36,6 +36,34 @@ describe('createNarration type constraints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a shared built-in default that also works for Russian', () => {
|
||||
createNarration({
|
||||
voice: { name: voices.Ava },
|
||||
en: { intro: 'Hello' },
|
||||
ru: { intro: 'Privet' },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a shared built-in default that is unavailable for Russian', () => {
|
||||
createNarration({
|
||||
// @ts-expect-error — this built-in voice is not available for Russian
|
||||
voice: { name: voices.Sophie },
|
||||
en: { intro: 'Hello' },
|
||||
ru: { intro: 'Privet' },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an unavailable Russian built-in per-language override', () => {
|
||||
createNarration({
|
||||
en: { intro: 'Hello' },
|
||||
ru: {
|
||||
// @ts-expect-error — this built-in voice is not available for Russian
|
||||
voice: { name: voices.Sophie },
|
||||
intro: 'Privet',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts mixed value types for the same key', () => {
|
||||
createNarration({
|
||||
voice: { name: voices.Ava },
|
||||
|
||||
+19
-14
@@ -12,13 +12,15 @@ import type {
|
||||
import { parseTimelineOffset, type TimelineOffset } from './timelineOffset.js'
|
||||
import { validateCrop, resolveSourceTrim } from './sourceTrim.js'
|
||||
import {
|
||||
defaultBuiltInVoice,
|
||||
supportedLanguages,
|
||||
voices,
|
||||
type VoiceKey,
|
||||
type Lang,
|
||||
type CustomVoiceRef,
|
||||
} from './voices.js'
|
||||
import type {
|
||||
AnyLangNarrationOverride,
|
||||
AnyTopLevelVoiceConfig,
|
||||
TopLevelVoiceConfig,
|
||||
LangNarrationOverride,
|
||||
} from './voiceConfig.js'
|
||||
@@ -292,13 +294,14 @@ type UnionToIntersection<U> = (
|
||||
* Uses each language's key set with CueMapValue values before intersecting,
|
||||
* so value types don't conflict (e.g. string vs { path, subtitle } for the same key).
|
||||
*/
|
||||
type LanguageEntryBase = {
|
||||
voice?: LangNarrationOverride
|
||||
type LanguageEntryBase<L extends Lang = Lang> = {
|
||||
voice?: LangNarrationOverride<L>
|
||||
}
|
||||
|
||||
type LanguageMetadataKey = keyof LanguageEntryBase
|
||||
|
||||
type NarrationLanguageInput = LanguageEntryBase & Record<string, unknown>
|
||||
type NarrationLanguageInput<L extends Lang = Lang> = LanguageEntryBase<L> &
|
||||
Record<string, unknown>
|
||||
|
||||
type LanguageEntry<C extends Record<string, CueMapValue>> = LanguageEntryBase &
|
||||
C
|
||||
@@ -331,14 +334,14 @@ type AllCues<M extends Partial<Record<Lang, NarrationLanguageInput>>> =
|
||||
type LanguagesMap<M extends Partial<Record<Lang, NarrationLanguageInput>>> =
|
||||
M & {
|
||||
[L in LanguageRootKeys<M>]-?: {
|
||||
voice?: LangNarrationOverride
|
||||
voice?: LangNarrationOverride<L>
|
||||
} & {
|
||||
[K in keyof OmitLanguageMetadata<NonNullable<M[L]>>]: CueMapValue
|
||||
} & AllCues<M>
|
||||
}
|
||||
|
||||
type NarrationInput<M extends Partial<Record<Lang, NarrationLanguageInput>>> = {
|
||||
voice?: TopLevelVoiceConfig
|
||||
voice?: TopLevelVoiceConfig<LanguageRootKeys<M>>
|
||||
} & LanguagesMap<M>
|
||||
|
||||
/**
|
||||
@@ -392,7 +395,7 @@ export function createNarration<
|
||||
const languages = normalizeLanguagesInput(input)
|
||||
|
||||
return buildCuesFromInput(
|
||||
input.voice ?? { name: voices.Sophie },
|
||||
input.voice ?? { name: defaultBuiltInVoice },
|
||||
languages
|
||||
) as Cues<AllCues<M>>
|
||||
}
|
||||
@@ -649,8 +652,10 @@ function voiceToKeyString(voice: VoiceKey | CustomVoiceRef): string {
|
||||
return voice
|
||||
}
|
||||
|
||||
function normalizeLanguagesInput(
|
||||
input: NarrationInput<Partial<Record<Lang, NarrationLanguageInput>>>
|
||||
function normalizeLanguagesInput<
|
||||
M extends Partial<Record<Lang, NarrationLanguageInput>>,
|
||||
>(
|
||||
input: NarrationInput<M>
|
||||
): Partial<Record<Lang, LanguageEntry<Record<string, CueMapValue>>>> {
|
||||
const languages: Partial<
|
||||
Record<Lang, LanguageEntry<Record<string, CueMapValue>>>
|
||||
@@ -674,7 +679,7 @@ function normalizeLanguagesInput(
|
||||
}
|
||||
|
||||
function buildCuesFromInput(
|
||||
topVoice: TopLevelVoiceConfig,
|
||||
topVoice: AnyTopLevelVoiceConfig,
|
||||
languages: Partial<Record<Lang, LanguageEntry<Record<string, CueMapValue>>>>
|
||||
): Cues<Record<string, CueMapValue>> {
|
||||
const langs = Object.keys(languages) as Lang[]
|
||||
@@ -949,7 +954,7 @@ async function entryToVideoTranslation(
|
||||
* provider setting comes from this one config.
|
||||
*/
|
||||
export function resolveVoiceMeta(
|
||||
config: TopLevelVoiceConfig | LangNarrationOverride
|
||||
config: AnyTopLevelVoiceConfig | AnyLangNarrationOverride
|
||||
): { name: VoiceKey | CustomVoiceRef; meta: VoiceLanguageMeta } {
|
||||
const name = config.name
|
||||
const seed = 'seed' in config ? config.seed : undefined
|
||||
@@ -1038,8 +1043,8 @@ function prewarmNarrationMedia(
|
||||
|
||||
export function buildLocalizedNarrationCues(
|
||||
narration: NormalizedNarration,
|
||||
voiceByLang: Partial<Record<string, LangNarrationOverride>>,
|
||||
defaultVoice: TopLevelVoiceConfig | LangNarrationOverride | undefined,
|
||||
voiceByLang: Partial<Record<string, AnyLangNarrationOverride>>,
|
||||
defaultVoice: AnyTopLevelVoiceConfig | AnyLangNarrationOverride | undefined,
|
||||
// The `.screenci` script media paths are resolved relative to. When provided,
|
||||
// every file-backed cue's media is pre-warmed (hashed) now, before the
|
||||
// recording clock starts, so the cue's start() reuses the cached hash instead
|
||||
@@ -1101,7 +1106,7 @@ export function buildLocalizedNarrationCues(
|
||||
const value = narration.seedByLang[lang as Lang]![cueName]!
|
||||
const config = (value.kind === 'text' ? value.voice : undefined) ??
|
||||
voiceByLang[lang] ??
|
||||
defaultVoice ?? { name: voices.Sophie }
|
||||
defaultVoice ?? { name: defaultBuiltInVoice }
|
||||
const { name, meta } = resolveVoiceMeta(config)
|
||||
return { value, voice: name, meta }
|
||||
}
|
||||
|
||||
+1
-1
@@ -155,7 +155,7 @@ describe('generateExampleVideo', () => {
|
||||
})`)
|
||||
// No per-language keys: the example does not declare a second language.
|
||||
expect(source).not.toContain(' es: {')
|
||||
// Narration defaults to the built-in voice (Sophie), so the example carries no
|
||||
// Narration defaults to the built-in voice (Ava), so the example carries no
|
||||
// redundant voice config and does not import `voices`.
|
||||
expect(source).not.toContain('video.use({')
|
||||
expect(source).not.toContain('voices')
|
||||
|
||||
@@ -101,6 +101,31 @@ describe('video.localize typed fixtures', () => {
|
||||
})('T', async () => {})
|
||||
})
|
||||
|
||||
it('rejects an unavailable Russian per-language built-in voice', () => {
|
||||
video.localize({
|
||||
voice: {
|
||||
// @ts-expect-error this built-in voice is not available for Russian
|
||||
ru: { name: voices.Sophie },
|
||||
},
|
||||
narration: { en: { intro: 'Hi' }, ru: { intro: 'Privet' } },
|
||||
})('T', async () => {})
|
||||
})
|
||||
|
||||
it('rejects an unavailable Russian per-cue built-in voice', () => {
|
||||
video.localize({
|
||||
narration: {
|
||||
en: { intro: 'Hi' },
|
||||
ru: {
|
||||
intro: {
|
||||
cue: 'Privet',
|
||||
// @ts-expect-error this built-in voice is not available for Russian
|
||||
voice: { name: voices.Sophie },
|
||||
},
|
||||
},
|
||||
},
|
||||
})('T', async () => {})
|
||||
})
|
||||
|
||||
it('accepts a per-cue synthesis language on a { cue } value', () => {
|
||||
video.localize({
|
||||
narration: {
|
||||
|
||||
+18
-14
@@ -1,5 +1,8 @@
|
||||
import { supportedLanguages, type Lang } from './voices.js'
|
||||
import type { LangNarrationOverride } from './voiceConfig.js'
|
||||
import type {
|
||||
AnyLangNarrationOverride,
|
||||
LangNarrationOverride,
|
||||
} from './voiceConfig.js'
|
||||
import type { OverlayCrop, SourceTrimPoint } from './events.js'
|
||||
import type { TimelineOffset } from './timelineOffset.js'
|
||||
import { validateCrop, resolveSourceTrim } from './sourceTrim.js'
|
||||
@@ -9,7 +12,8 @@ import { validateCrop, resolveSourceTrim } from './sourceTrim.js'
|
||||
* sit on a per-language or per-cue voice), discriminated by voice name like the
|
||||
* narration voice overrides.
|
||||
*/
|
||||
export type VoiceConfig = LangNarrationOverride
|
||||
export type VoiceConfig<L extends Lang = Lang> = LangNarrationOverride<L>
|
||||
type AnyVoiceConfig = AnyLangNarrationOverride
|
||||
|
||||
/**
|
||||
* How a localized video/screenshot is recorded.
|
||||
@@ -40,16 +44,16 @@ type LocalizeNarrationMediaFields = {
|
||||
end?: TimelineOffset
|
||||
language?: never
|
||||
}
|
||||
export type LocalizeNarrationValue =
|
||||
export type LocalizeNarrationValue<L extends Lang = Lang> =
|
||||
| string
|
||||
| { cue: string; voice?: VoiceConfig; language?: Lang; volume?: number }
|
||||
| { cue: string; voice?: VoiceConfig<L>; language?: Lang; volume?: number }
|
||||
| ({ media: string } & LocalizeNarrationMediaFields)
|
||||
| ({ path: string } & LocalizeNarrationMediaFields)
|
||||
|
||||
/** Seeded narration: language -> (cue name -> value). */
|
||||
export type NarrationByLang = Partial<
|
||||
Record<Lang, Record<string, LocalizeNarrationValue>>
|
||||
>
|
||||
export type NarrationByLang = Partial<{
|
||||
[L in Lang]: Record<string, LocalizeNarrationValue<L>>
|
||||
}>
|
||||
|
||||
/** Seeded values: language -> (field name -> string). */
|
||||
export type ValuesByLang = Partial<Record<Lang, Record<string, string>>>
|
||||
@@ -61,7 +65,7 @@ export type ValuesByLang = Partial<Record<Lang, Record<string, string>>>
|
||||
* config-for-all-languages form here: the all-languages default belongs in
|
||||
* `use`, so the localize `voice` only carries per-language overrides.
|
||||
*/
|
||||
export type LocalizeVoiceSpec = Partial<Record<Lang, VoiceConfig>>
|
||||
export type LocalizeVoiceSpec = Partial<{ [L in Lang]: VoiceConfig<L> }>
|
||||
|
||||
/**
|
||||
* The internal localization spec assembled from the per-feature builders
|
||||
@@ -99,7 +103,7 @@ export type NormalizedCueValue =
|
||||
| {
|
||||
kind: 'text'
|
||||
text: string
|
||||
voice?: VoiceConfig
|
||||
voice?: AnyVoiceConfig
|
||||
language?: Lang
|
||||
volume?: number
|
||||
}
|
||||
@@ -143,7 +147,7 @@ export type NormalizedLocalize = {
|
||||
narration: NormalizedNarration
|
||||
values: NormalizedValues
|
||||
/** Per-language localize `voice` overrides (absent language = use config default). */
|
||||
voiceByLang: Partial<Record<Lang, VoiceConfig>>
|
||||
voiceByLang: Partial<Record<Lang, AnyVoiceConfig>>
|
||||
}
|
||||
|
||||
const SUPPORTED_LANGUAGE_SET = new Set<string>(supportedLanguages)
|
||||
@@ -176,9 +180,9 @@ function collectSeededNames(
|
||||
return out
|
||||
}
|
||||
|
||||
export function normalizeCueValue(
|
||||
export function normalizeCueValue<L extends Lang>(
|
||||
name: string,
|
||||
value: LocalizeNarrationValue
|
||||
value: LocalizeNarrationValue<L>
|
||||
): NormalizedCueValue {
|
||||
if (typeof value === 'string') {
|
||||
return { kind: 'text', text: value }
|
||||
@@ -322,7 +326,7 @@ function normalizeValues(input: ValuesByLang | undefined): {
|
||||
function normalizeVoice(
|
||||
voice: LocalizeVoiceSpec | undefined,
|
||||
languages: readonly string[]
|
||||
): Partial<Record<Lang, VoiceConfig>> {
|
||||
): Partial<Record<Lang, AnyVoiceConfig>> {
|
||||
if (voice === undefined) return {}
|
||||
// Per-language overrides only. Languages omitted from the map fall back to the
|
||||
// config/global default voice set via `use`, so the map may be partial; it may
|
||||
@@ -335,7 +339,7 @@ function normalizeVoice(
|
||||
)} that are not part of this localization (${languages.join(', ')}).`
|
||||
)
|
||||
}
|
||||
const out: Partial<Record<Lang, VoiceConfig>> = {}
|
||||
const out: Partial<Record<Lang, AnyVoiceConfig>> = {}
|
||||
for (const [lang, config] of Object.entries(voice)) {
|
||||
if (config !== undefined) out[lang as Lang] = config
|
||||
}
|
||||
|
||||
@@ -302,7 +302,7 @@ describe('buildNarrationMarkers', () => {
|
||||
'en',
|
||||
])
|
||||
await markers.intro()
|
||||
expect(voiceOf('en')).toBe(voices.Sophie)
|
||||
expect(voiceOf('en')).toBe(voices.Ava)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -4,12 +4,15 @@ import {
|
||||
type LocalizeNarrationValue,
|
||||
type NormalizedCueValue,
|
||||
type NormalizedNarration,
|
||||
type VoiceConfig,
|
||||
} from './localize.js'
|
||||
import type { NormalizedFeature } from './declare.js'
|
||||
import type { Lang } from './voices.js'
|
||||
import type { ValuesOverrides } from './runtimeMode.js'
|
||||
import type { TopLevelVoiceConfig } from './voiceConfig.js'
|
||||
import type {
|
||||
AnyLangNarrationOverride,
|
||||
AnyTopLevelVoiceConfig,
|
||||
TopLevelVoiceConfig,
|
||||
} from './voiceConfig.js'
|
||||
import type { RenderOptions } from './types.js'
|
||||
|
||||
/**
|
||||
@@ -73,8 +76,8 @@ function featureToNormalizedNarration(
|
||||
export function buildNarrationMarkers(
|
||||
narration: NormalizedFeature<LocalizeNarrationValue> | null | undefined,
|
||||
languages: string[],
|
||||
defaultVoice?: TopLevelVoiceConfig,
|
||||
voiceByLang: Partial<Record<string, VoiceConfig>> = {},
|
||||
defaultVoice?: AnyTopLevelVoiceConfig,
|
||||
voiceByLang: Partial<Record<string, AnyLangNarrationOverride>> = {},
|
||||
// The `.screenci` script that media paths resolve against. When provided, the
|
||||
// file-backed cues' media is pre-warmed (hashed) up front so their start()
|
||||
// does not pay the read on the recording timeline. Omitted outside recording.
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
setCurrentZoomViewport,
|
||||
} from './autoZoom.js'
|
||||
import { DEFAULT_SCROLL_CENTERING } from './defaults.js'
|
||||
import { hide, setActiveHideRecorder } from './hide.js'
|
||||
import { resetZoom, zoomTo } from './manualZoom.js'
|
||||
|
||||
type MockDoc = {
|
||||
@@ -135,12 +136,14 @@ describe('manual zoom', () => {
|
||||
})
|
||||
setCurrentZoomViewport(null)
|
||||
setActiveAutoZoomRecorder(NOOP_EVENT_RECORDER)
|
||||
setActiveHideRecorder(NOOP_EVENT_RECORDER)
|
||||
setActiveZoomPage(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
setActiveAutoZoomRecorder(NOOP_EVENT_RECORDER)
|
||||
setActiveHideRecorder(NOOP_EVENT_RECORDER)
|
||||
setActiveZoomPage(null)
|
||||
})
|
||||
|
||||
@@ -175,6 +178,36 @@ describe('manual zoom', () => {
|
||||
).toBe('focusChange')
|
||||
})
|
||||
|
||||
it('updates manual zoom inside hide() without recording a focusChange input', async () => {
|
||||
const recorder = new EventRecorder()
|
||||
recorder.start()
|
||||
setActiveAutoZoomRecorder(recorder)
|
||||
setActiveHideRecorder(recorder)
|
||||
|
||||
const locator = makeLocatorMock({
|
||||
rect: { x: 900, y: 500, width: 80, height: 40 },
|
||||
viewport: { width: 1280, height: 720 },
|
||||
scrollSize: { width: 1280, height: 720 },
|
||||
})
|
||||
|
||||
const promise = hide(async () => {
|
||||
await zoomTo(locator, {
|
||||
amount: 0.5,
|
||||
centering: 1,
|
||||
duration: 300,
|
||||
})
|
||||
})
|
||||
await vi.runAllTimersAsync()
|
||||
await promise
|
||||
|
||||
const events = recorder.getEvents()
|
||||
expect(events).toHaveLength(3)
|
||||
expect(events[1]).toMatchObject({ type: 'hideStart' })
|
||||
expect(events[2]).toMatchObject({ type: 'hideEnd' })
|
||||
expect(getAutoZoomState().mode).toBe('manual')
|
||||
expect(getAutoZoomState().currentZoomViewport).not.toBeNull()
|
||||
})
|
||||
|
||||
it('computes a stable point target for zoomTo({ x, y })', async () => {
|
||||
const recorder = new EventRecorder()
|
||||
recorder.start()
|
||||
@@ -285,6 +318,41 @@ describe('manual zoom', () => {
|
||||
expect(getAutoZoomState().mode).toBe('idle')
|
||||
})
|
||||
|
||||
it('resets zoom inside hide() without recording a focusChange input', async () => {
|
||||
const recorder = new EventRecorder()
|
||||
recorder.start()
|
||||
setActiveAutoZoomRecorder(recorder)
|
||||
setActiveHideRecorder(recorder)
|
||||
|
||||
setAutoZoomState({
|
||||
insideAutoZoom: false,
|
||||
mode: 'manual',
|
||||
options: {},
|
||||
scrollCentering: DEFAULT_SCROLL_CENTERING,
|
||||
currentZoomViewport: {
|
||||
focusPoint: { x: 640, y: 360 },
|
||||
viewportSize: { width: 1280, height: 720 },
|
||||
optimalOffset: { x: 0, y: 0 },
|
||||
end: {
|
||||
pointPx: { x: 640, y: 360 },
|
||||
size: { widthPx: 640, heightPx: 360 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const promise = hide(async () => {
|
||||
await resetZoom({ duration: 300, postZoomDelay: 0 })
|
||||
})
|
||||
await vi.runAllTimersAsync()
|
||||
await promise
|
||||
|
||||
const events = recorder.getEvents()
|
||||
expect(events).toHaveLength(3)
|
||||
expect(events[1]).toMatchObject({ type: 'hideStart' })
|
||||
expect(events[2]).toMatchObject({ type: 'hideEnd' })
|
||||
expect(getAutoZoomState().mode).toBe('idle')
|
||||
})
|
||||
|
||||
it('throws when autoZoom starts while manual zoom is active, and vice versa', async () => {
|
||||
setActiveZoomPage(makePageMock())
|
||||
|
||||
|
||||
+8
-3
@@ -2,6 +2,7 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import { DEFAULT_ZOOM_OPTIONS } from './defaults.js'
|
||||
import { ScreenciError } from './errors.js'
|
||||
import type { FocusChangeEvent } from './events.js'
|
||||
import { isInsideHide } from './hide.js'
|
||||
import { changeFocus, resolvePointFocusZoom } from './changeFocus.js'
|
||||
import {
|
||||
getActiveAutoZoomRecorder,
|
||||
@@ -157,12 +158,14 @@ export async function zoomTo(
|
||||
setZoomMode(
|
||||
result.zoom !== undefined || previousMode === 'manual' ? 'manual' : 'idle'
|
||||
)
|
||||
recorder.addInput('focusChange', result.elementRect, [result])
|
||||
if (!isInsideHide()) {
|
||||
recorder.addInput('focusChange', result.elementRect, [result])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const result = await zoomToPoint(target, options)
|
||||
if (result !== undefined) {
|
||||
if (result !== undefined && !isInsideHide()) {
|
||||
recorder.addInput('focusChange', undefined, [result])
|
||||
}
|
||||
}
|
||||
@@ -228,5 +231,7 @@ export async function resetZoom(options: AutoZoomOptions = {}): Promise<void> {
|
||||
})
|
||||
setZoomMode('idle')
|
||||
|
||||
recorder.addInput('focusChange', viewport.elementRect, [result])
|
||||
if (!isInsideHide()) {
|
||||
recorder.addInput('focusChange', viewport.elementRect, [result])
|
||||
}
|
||||
}
|
||||
|
||||
+30
-7
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ModelVoiceKey,
|
||||
Lang,
|
||||
BuiltInVoiceKeyForLang,
|
||||
ElevenLabsVoiceKey,
|
||||
CustomVoiceRef,
|
||||
ModelType,
|
||||
@@ -34,14 +35,16 @@ type ElevenLabsVoiceConfig = ElevenLabsVoiceSettings & {
|
||||
pacing?: never
|
||||
}
|
||||
|
||||
type BuiltInVoiceName<L extends Lang = Lang> = BuiltInVoiceKeyForLang<L>
|
||||
|
||||
/**
|
||||
* Default voice shared across all languages. `seed` is not allowed here — use a
|
||||
* per-language voice override instead.
|
||||
*/
|
||||
export type TopLevelVoiceConfig =
|
||||
export type TopLevelVoiceConfig<L extends Lang = Lang> =
|
||||
| ElevenLabsVoiceConfig
|
||||
| {
|
||||
name: ModelVoiceKey
|
||||
name: BuiltInVoiceName<L>
|
||||
/** Speaking style prompt for expressive synthesis. Business tier only. Implies `expressive` model type. */
|
||||
style: string
|
||||
/** Can be omitted when `style` is set — `expressive` is implied. Business tier only. */
|
||||
@@ -59,7 +62,7 @@ export type TopLevelVoiceConfig =
|
||||
pacing?: string
|
||||
}
|
||||
| {
|
||||
name: ModelVoiceKey
|
||||
name: BuiltInVoiceName<L>
|
||||
style?: never
|
||||
accent?: never
|
||||
/** Speaking rate for consistent synthesis. Valid range: 0.25 to 2. */
|
||||
@@ -76,7 +79,7 @@ export type TopLevelVoiceConfig =
|
||||
* use expressive/consistent controls, ElevenLabs voices use the numeric
|
||||
* `eleven_multilingual_v2` controls.
|
||||
*/
|
||||
export type LangNarrationOverride =
|
||||
export type LangNarrationOverride<L extends Lang = Lang> =
|
||||
| (ElevenLabsVoiceConfig & {
|
||||
/**
|
||||
* Integer seed included in the audio cache key and forwarded to ElevenLabs.
|
||||
@@ -85,7 +88,7 @@ export type LangNarrationOverride =
|
||||
seed?: number
|
||||
})
|
||||
| {
|
||||
name: ModelVoiceKey
|
||||
name: BuiltInVoiceName<L>
|
||||
/**
|
||||
* Integer seed included in the audio cache key. A different seed always forces
|
||||
* regeneration. Consistent output is not guaranteed across all voice types.
|
||||
@@ -108,7 +111,7 @@ export type LangNarrationOverride =
|
||||
pacing?: string
|
||||
}
|
||||
| {
|
||||
name: ModelVoiceKey
|
||||
name: BuiltInVoiceName<L>
|
||||
/**
|
||||
* Integer seed included in the audio cache key. A different seed always forces
|
||||
* regeneration. Consistent output is not guaranteed across all voice types.
|
||||
@@ -121,3 +124,23 @@ export type LangNarrationOverride =
|
||||
/** TTS model type — `modelTypes.expressive` or `modelTypes.consistent`. Defaults to `consistent`. */
|
||||
modelType?: Exclude<ModelType, 'expressive'> | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of every language-specific top-level voice config. Unlike
|
||||
* `TopLevelVoiceConfig<Lang>`, this does not collapse to the Russian-safe
|
||||
* subset; it preserves each language's valid config as a separate branch.
|
||||
*/
|
||||
export type AnyTopLevelVoiceConfig = {
|
||||
[L in Lang]: TopLevelVoiceConfig<L>
|
||||
}[Lang]
|
||||
|
||||
/**
|
||||
* Union of every language-specific per-language narration override.
|
||||
*
|
||||
* Useful for normalized runtime shapes that store already-resolved per-language
|
||||
* voice configs without reapplying the shared-subset narrowing used by public
|
||||
* APIs that span multiple languages.
|
||||
*/
|
||||
export type AnyLangNarrationOverride = {
|
||||
[L in Lang]: LangNarrationOverride<L>
|
||||
}[Lang]
|
||||
|
||||
+37
-2
@@ -25,8 +25,11 @@ export type ModelType = (typeof modelTypes)[keyof typeof modelTypes]
|
||||
/**
|
||||
* Named voices available for the narration voice (`renderOptions.narration`).
|
||||
*
|
||||
* Built-in voices are language-agnostic; set a default and override per language
|
||||
* with `voices`:
|
||||
* Built-in voices share one public catalog across supported languages. Most work
|
||||
* everywhere; when a language has a smaller built-in subset, TypeScript narrows
|
||||
* the allowed names for that language automatically.
|
||||
*
|
||||
* Set a default and override per language with `voices`:
|
||||
*
|
||||
* ```ts
|
||||
* video.use({
|
||||
@@ -261,6 +264,38 @@ export const supportedLanguages = supportedBaseLanguageCodes
|
||||
/** Union of supported language codes, e.g. `'en' | 'fi' | 'cmn'`. */
|
||||
export type Lang = SupportedBaseLanguageCode
|
||||
|
||||
/**
|
||||
* Built-in voice names available for Russian narration.
|
||||
*
|
||||
* Russian has a smaller built-in voice subset than most supported languages, so
|
||||
* the SDK narrows Russian voice configs to these names.
|
||||
*/
|
||||
export const russianBuiltInVoices = [
|
||||
voices.Ava,
|
||||
voices.Daniel,
|
||||
voices.Emma,
|
||||
voices.Leo,
|
||||
voices.Lily,
|
||||
voices.Max,
|
||||
voices.Miles,
|
||||
voices.Nora,
|
||||
] as const
|
||||
|
||||
export type RussianBuiltInVoiceKey = (typeof russianBuiltInVoices)[number]
|
||||
|
||||
/**
|
||||
* Built-in voice names available for a given narration language.
|
||||
*
|
||||
* For a language union, this resolves to the shared subset that is valid for
|
||||
* every language in the union.
|
||||
*/
|
||||
export type BuiltInVoiceKeyForLang<L extends Lang> = 'ru' extends L
|
||||
? RussianBuiltInVoiceKey
|
||||
: ModelVoiceKey
|
||||
|
||||
/** Built-in fallback voice used when no narration voice is configured. */
|
||||
export const defaultBuiltInVoice = voices.Ava
|
||||
|
||||
/**
|
||||
* A reference to a local audio or video file for ElevenLabs Instant Voice Cloning.
|
||||
* Build one with `voices.elevenlabs({ path })` and pass it as the voice `name`
|
||||
|
||||
Reference in New Issue
Block a user