mirror of
https://github.com/screenci/screenci.git
synced 2026-09-19 08:57:46 +08:00
feat: code-as-source-of-truth editing via dev codegen channel
Editor edits now arrive over the dev channel as codegen requests and are written straight into the .screenci.ts sources; recordings always run purely from code values. - Keep data.json across uploads (media-only cleanup, last-data.json rename) and stamp a sourceHash of the test file into its metadata. - screenci dev startup handshake: skip recording when the source hash matches and every editable action has an editId; otherwise stamp missing editIds and re-record as a preview. New --grep, --force-record and --record-kill-window flags. - Dev loop: records run in a killable background slot while the loop keeps polling; codegen requests apply serially via the codemod pipeline and are acked over /cli/dev/report-codegen; fast-poll during active editing; kill-or-queue policy for superseding record triggers; sync-state reporting locks editor timelines during the startup handshake. - Remove the server-override machinery: editable-actions.json and action-params.json snapshots, SCREENCI_TIMELINE_EDITS injection, runtime override application, screenci sync / status / reset-web-edits and dev --sync.
This commit is contained in:
+291
-10
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
DevCodegenRequest,
|
||||
DevListenConfig,
|
||||
DevListenDeps,
|
||||
DevTrigger,
|
||||
@@ -32,6 +33,14 @@ const trigger: DevTrigger = {
|
||||
requestedByName: 'Olli',
|
||||
}
|
||||
|
||||
const codegenRequest: DevCodegenRequest = {
|
||||
requestId: 'cgr_1',
|
||||
videoName: 'Intro video',
|
||||
editId: 'delay1',
|
||||
editJson: '{"kind":"paramEdit"}',
|
||||
requiresRecord: true,
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
@@ -88,20 +97,26 @@ describe('registerDevListener', () => {
|
||||
})
|
||||
|
||||
describe('pollDevListener', () => {
|
||||
it('returns the claimed trigger', async () => {
|
||||
it('returns the claimed trigger and codegen requests', async () => {
|
||||
const deps = makeDeps()
|
||||
deps.fetchMock.mockResolvedValueOnce(jsonResponse({ trigger }))
|
||||
|
||||
await expect(pollDevListener(config, deps, 'lst_1')).resolves.toEqual(
|
||||
trigger
|
||||
deps.fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ trigger, codegenRequests: [codegenRequest] })
|
||||
)
|
||||
|
||||
await expect(pollDevListener(config, deps, 'lst_1')).resolves.toEqual({
|
||||
trigger,
|
||||
codegenRequests: [codegenRequest],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when nothing is pending', async () => {
|
||||
it('returns an empty result when nothing is pending', async () => {
|
||||
const deps = makeDeps()
|
||||
deps.fetchMock.mockResolvedValueOnce(jsonResponse({ trigger: null }))
|
||||
|
||||
await expect(pollDevListener(config, deps, 'lst_1')).resolves.toBeNull()
|
||||
await expect(pollDevListener(config, deps, 'lst_1')).resolves.toEqual({
|
||||
trigger: null,
|
||||
codegenRequests: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('treats an empty 2xx body as no trigger instead of throwing', async () => {
|
||||
@@ -110,7 +125,10 @@ describe('pollDevListener', () => {
|
||||
// res.json() on it throws "Unexpected end of JSON input".
|
||||
deps.fetchMock.mockResolvedValueOnce(new Response('', { status: 200 }))
|
||||
|
||||
await expect(pollDevListener(config, deps, 'lst_1')).resolves.toBeNull()
|
||||
await expect(pollDevListener(config, deps, 'lst_1')).resolves.toEqual({
|
||||
trigger: null,
|
||||
codegenRequests: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -137,7 +155,7 @@ describe('runDevListenLoop', () => {
|
||||
|
||||
await runDevListenLoop(config, deps, 'lst_1', controller)
|
||||
|
||||
expect(runRecord).toHaveBeenCalledWith(trigger)
|
||||
expect(runRecord).toHaveBeenCalledWith(trigger, expect.any(AbortSignal))
|
||||
const reports = deps.fetchMock.mock.calls
|
||||
.filter(([url]) => (url as string).endsWith('/cli/dev/report'))
|
||||
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
|
||||
@@ -164,10 +182,273 @@ describe('runDevListenLoop', () => {
|
||||
await runDevListenLoop(config, deps, 'lst_1', controller)
|
||||
|
||||
expect(runRecord).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ previewOnly: true })
|
||||
expect.objectContaining({ previewOnly: true }),
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
})
|
||||
|
||||
it('applies codegen requests and reports them applied', async () => {
|
||||
const controller = { stopped: false }
|
||||
const applyCodegen = vi.fn(async () => {})
|
||||
const deps = makeDeps({ applyCodegen })
|
||||
deps.fetchMock.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/cli/dev/poll')) {
|
||||
if (deps.fetchMock.mock.calls.length === 1) {
|
||||
return jsonResponse({
|
||||
trigger: null,
|
||||
codegenRequests: [codegenRequest],
|
||||
})
|
||||
}
|
||||
controller.stopped = true
|
||||
return jsonResponse({ trigger: null })
|
||||
}
|
||||
return jsonResponse({ ok: true })
|
||||
})
|
||||
|
||||
await runDevListenLoop(config, deps, 'lst_1', controller)
|
||||
|
||||
expect(applyCodegen).toHaveBeenCalledWith(codegenRequest)
|
||||
const reports = deps.fetchMock.mock.calls
|
||||
.filter(([url]) => (url as string).endsWith('/cli/dev/report-codegen'))
|
||||
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
|
||||
expect(reports).toEqual([
|
||||
expect.objectContaining({
|
||||
requestId: 'cgr_1',
|
||||
state: 'applied',
|
||||
listenerId: 'lst_1',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('reports codegen failed with the error message when the apply throws', async () => {
|
||||
const controller = { stopped: false }
|
||||
const applyCodegen = vi.fn(async () => {
|
||||
throw new Error('editId not found in source')
|
||||
})
|
||||
const deps = makeDeps({ applyCodegen })
|
||||
deps.fetchMock.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/cli/dev/poll')) {
|
||||
if (deps.fetchMock.mock.calls.length === 1) {
|
||||
return jsonResponse({ codegenRequests: [codegenRequest] })
|
||||
}
|
||||
controller.stopped = true
|
||||
return jsonResponse({})
|
||||
}
|
||||
return jsonResponse({ ok: true })
|
||||
})
|
||||
|
||||
await runDevListenLoop(config, deps, 'lst_1', controller)
|
||||
|
||||
const reports = deps.fetchMock.mock.calls
|
||||
.filter(([url]) => (url as string).endsWith('/cli/dev/report-codegen'))
|
||||
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
|
||||
expect(reports).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'failed',
|
||||
errorMessage: 'editId not found in source',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('serves codegen requests while a record is running', async () => {
|
||||
const controller = { stopped: false }
|
||||
let releaseRecord = () => {}
|
||||
const recordGate = new Promise<void>((resolve) => {
|
||||
releaseRecord = resolve
|
||||
})
|
||||
const runRecord = vi.fn(async () => recordGate)
|
||||
const applyCodegen = vi.fn(async () => {})
|
||||
const deps = makeDeps({ runRecord, applyCodegen })
|
||||
let polls = 0
|
||||
deps.fetchMock.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/cli/dev/poll')) {
|
||||
polls += 1
|
||||
if (polls === 1) return jsonResponse({ trigger })
|
||||
if (polls === 2) {
|
||||
// The record is still running; the codegen must be served anyway.
|
||||
return jsonResponse({ codegenRequests: [codegenRequest] })
|
||||
}
|
||||
// Stop only once the codegen was served mid-record.
|
||||
if (applyCodegen.mock.calls.length > 0) {
|
||||
expect(runRecord).toHaveBeenCalled()
|
||||
controller.stopped = true
|
||||
releaseRecord()
|
||||
}
|
||||
return jsonResponse({})
|
||||
}
|
||||
return jsonResponse({ ok: true })
|
||||
})
|
||||
|
||||
await runDevListenLoop(config, deps, 'lst_1', controller)
|
||||
|
||||
expect(applyCodegen).toHaveBeenCalledWith(codegenRequest)
|
||||
const triggerReports = deps.fetchMock.mock.calls
|
||||
.filter(([url]) => (url as string).endsWith('/cli/dev/report'))
|
||||
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
|
||||
expect(triggerReports.map((r) => r.state)).toEqual(['running', 'done'])
|
||||
})
|
||||
|
||||
it('kills a young record when a new trigger arrives and runs the new one', async () => {
|
||||
const controller = { stopped: false }
|
||||
let clock = 0
|
||||
const secondTrigger: DevTrigger = { ...trigger, triggerId: 'trg_2' }
|
||||
const runRecord = vi.fn(
|
||||
(t: DevTrigger, signal?: AbortSignal) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
if (t.triggerId === 'trg_2') {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
// First record: runs until killed.
|
||||
signal?.addEventListener('abort', () =>
|
||||
reject(new Error('Record aborted'))
|
||||
)
|
||||
})
|
||||
)
|
||||
const deps = makeDeps({ runRecord, now: () => clock })
|
||||
const seenReports: Array<{ triggerId: string; state: string }> = []
|
||||
let polls = 0
|
||||
deps.fetchMock.mockImplementation(
|
||||
async (url: string, init?: RequestInit) => {
|
||||
if (url.endsWith('/cli/dev/report')) {
|
||||
seenReports.push(JSON.parse((init as RequestInit).body as string))
|
||||
}
|
||||
if (url.endsWith('/cli/dev/poll')) {
|
||||
polls += 1
|
||||
if (polls === 1) return jsonResponse({ trigger })
|
||||
if (polls === 2) {
|
||||
clock = 5_000 // Inside the 10s kill window.
|
||||
return jsonResponse({ trigger: secondTrigger })
|
||||
}
|
||||
// Stop only once the replacement record has reported done.
|
||||
if (
|
||||
seenReports.some(
|
||||
(r) => r.triggerId === 'trg_2' && r.state === 'done'
|
||||
)
|
||||
) {
|
||||
controller.stopped = true
|
||||
}
|
||||
return jsonResponse({})
|
||||
}
|
||||
return jsonResponse({ ok: true })
|
||||
}
|
||||
)
|
||||
|
||||
await runDevListenLoop(config, deps, 'lst_1', controller)
|
||||
|
||||
expect(runRecord).toHaveBeenCalledTimes(2)
|
||||
const reports = deps.fetchMock.mock.calls
|
||||
.filter(([url]) => (url as string).endsWith('/cli/dev/report'))
|
||||
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
|
||||
const first = reports.filter((r) => r.triggerId === 'trg_1')
|
||||
const second = reports.filter((r) => r.triggerId === 'trg_2')
|
||||
expect(first.map((r) => r.state)).toEqual(['running', 'failed'])
|
||||
expect(first[1].errorMessage).toContain('Superseded')
|
||||
expect(second.map((r) => r.state)).toEqual(['running', 'done'])
|
||||
})
|
||||
|
||||
it('queues behind an old record instead of killing it, latest trigger wins', async () => {
|
||||
const controller = { stopped: false }
|
||||
let clock = 0
|
||||
let releaseFirst = () => {}
|
||||
const firstGate = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
const secondTrigger: DevTrigger = { ...trigger, triggerId: 'trg_2' }
|
||||
const thirdTrigger: DevTrigger = { ...trigger, triggerId: 'trg_3' }
|
||||
const runRecord = vi.fn((t: DevTrigger) =>
|
||||
t.triggerId === 'trg_1' ? firstGate : Promise.resolve()
|
||||
)
|
||||
const deps = makeDeps({ runRecord, now: () => clock })
|
||||
const seenReports: Array<{ triggerId: string; state: string }> = []
|
||||
let polls = 0
|
||||
deps.fetchMock.mockImplementation(
|
||||
async (url: string, init?: RequestInit) => {
|
||||
if (url.endsWith('/cli/dev/report')) {
|
||||
seenReports.push(JSON.parse((init as RequestInit).body as string))
|
||||
}
|
||||
if (url.endsWith('/cli/dev/poll')) {
|
||||
polls += 1
|
||||
if (polls === 1) return jsonResponse({ trigger })
|
||||
if (polls === 2) {
|
||||
clock = 60_000 // Past the kill window: must queue, not kill.
|
||||
return jsonResponse({ trigger: secondTrigger })
|
||||
}
|
||||
if (polls === 3) {
|
||||
// A newer trigger replaces the queued one (latest wins).
|
||||
return jsonResponse({ trigger: thirdTrigger })
|
||||
}
|
||||
if (polls === 4) {
|
||||
releaseFirst()
|
||||
return jsonResponse({})
|
||||
}
|
||||
// Stop only once the queued trigger has reported done.
|
||||
if (
|
||||
seenReports.some(
|
||||
(r) => r.triggerId === 'trg_3' && r.state === 'done'
|
||||
)
|
||||
) {
|
||||
controller.stopped = true
|
||||
}
|
||||
return jsonResponse({})
|
||||
}
|
||||
return jsonResponse({ ok: true })
|
||||
}
|
||||
)
|
||||
|
||||
await runDevListenLoop(config, deps, 'lst_1', controller)
|
||||
|
||||
// trg_1 completes, trg_2 was replaced in the queue, trg_3 runs after.
|
||||
const recorded = runRecord.mock.calls.map(
|
||||
(call) => (call[0] as DevTrigger).triggerId
|
||||
)
|
||||
expect(recorded).toEqual(['trg_1', 'trg_3'])
|
||||
const reports = deps.fetchMock.mock.calls
|
||||
.filter(([url]) => (url as string).endsWith('/cli/dev/report'))
|
||||
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
|
||||
const byTrigger = (id: string) =>
|
||||
reports.filter((r) => r.triggerId === id).map((r) => r.state)
|
||||
expect(byTrigger('trg_1')).toEqual(['running', 'done'])
|
||||
expect(byTrigger('trg_2')).toEqual(['failed'])
|
||||
expect(byTrigger('trg_3')).toEqual(['running', 'done'])
|
||||
})
|
||||
|
||||
it('polls faster inside the activity window after receiving work', async () => {
|
||||
const controller = { stopped: false }
|
||||
let clock = 0
|
||||
const sleep = vi.fn(async () => {})
|
||||
const deps = makeDeps({ sleep, now: () => clock })
|
||||
const fastConfig: DevListenConfig = {
|
||||
...config,
|
||||
pollIntervalMs: 2_500,
|
||||
fastPollIntervalMs: 750,
|
||||
fastPollWindowMs: 60_000,
|
||||
}
|
||||
let polls = 0
|
||||
deps.fetchMock.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/cli/dev/poll')) {
|
||||
polls += 1
|
||||
if (polls === 1) return jsonResponse({}) // idle: slow poll
|
||||
if (polls === 2)
|
||||
return jsonResponse({ codegenRequests: [codegenRequest] })
|
||||
if (polls === 3) {
|
||||
clock = 120_000 // Window elapsed: back to slow polling.
|
||||
return jsonResponse({})
|
||||
}
|
||||
controller.stopped = true
|
||||
return jsonResponse({})
|
||||
}
|
||||
return jsonResponse({ ok: true })
|
||||
})
|
||||
|
||||
await runDevListenLoop(fastConfig, deps, 'lst_1', controller)
|
||||
|
||||
const delays = sleep.mock.calls.map((call) => call[0] as number)
|
||||
expect(delays[0]).toBe(2_500) // idle
|
||||
expect(delays[1]).toBe(750) // right after codegen activity
|
||||
expect(delays[2]).toBe(2_500) // window elapsed
|
||||
})
|
||||
|
||||
it('reports failed with the error message when the record throws', async () => {
|
||||
const controller = { stopped: false }
|
||||
const runRecord = vi.fn(async () => {
|
||||
|
||||
+59
-404
@@ -17,6 +17,10 @@ const mockExistsSync = vi.fn()
|
||||
const mockRealpathSync = vi.fn((path: string) => path)
|
||||
const mockMkdirSync = vi.fn()
|
||||
const mockRmSync = vi.fn()
|
||||
const mockRenameSync = vi.fn()
|
||||
const mockStatSync = vi.fn(
|
||||
() => undefined as undefined | { isDirectory: () => boolean }
|
||||
)
|
||||
const mockReaddirSync = vi.fn(() => [] as string[])
|
||||
const mockReadFileSync = vi.fn()
|
||||
const mockReaddir = vi.fn()
|
||||
@@ -180,6 +184,8 @@ vi.mock('fs', () => ({
|
||||
realpathSync: mockRealpathSync,
|
||||
mkdirSync: mockMkdirSync,
|
||||
rmSync: mockRmSync,
|
||||
renameSync: mockRenameSync,
|
||||
statSync: mockStatSync,
|
||||
readdirSync: mockReaddirSync,
|
||||
readFileSync: mockReadFileSync,
|
||||
default: {
|
||||
@@ -188,6 +194,8 @@ vi.mock('fs', () => ({
|
||||
realpathSync: mockRealpathSync,
|
||||
mkdirSync: mockMkdirSync,
|
||||
rmSync: mockRmSync,
|
||||
renameSync: mockRenameSync,
|
||||
statSync: mockStatSync,
|
||||
readdirSync: mockReaddirSync,
|
||||
readFileSync: mockReadFileSync,
|
||||
},
|
||||
@@ -264,6 +272,8 @@ describe('CLI', () => {
|
||||
return ''
|
||||
})
|
||||
mockStat.mockResolvedValue({ size: 4 })
|
||||
mockStatSync.mockReturnValue(undefined)
|
||||
mockReaddirSync.mockReturnValue([] as unknown as string[])
|
||||
mockCreateReadStream.mockImplementation(() => {
|
||||
const stream = new Readable({ read() {} })
|
||||
process.nextTick(() => {
|
||||
@@ -396,6 +406,33 @@ describe('CLI', () => {
|
||||
expect(removed).not.toContain('/project/.screenci/.overlay-cache')
|
||||
})
|
||||
|
||||
it('keeps a recording dir data.json as last-data.json for the freshness check', async () => {
|
||||
const { clearRecordingDirectories } = await import('./cli')
|
||||
const dir = '/project/.screenci'
|
||||
mockReaddirSync.mockImplementation(((path: string) =>
|
||||
path === dir
|
||||
? ['My Video [en]']
|
||||
: ['data.json', 'recording.mp4']) as never)
|
||||
mockStatSync.mockReturnValue({ isDirectory: () => true })
|
||||
mockExistsSync.mockImplementation((path: string) =>
|
||||
path.endsWith('data.json')
|
||||
)
|
||||
|
||||
clearRecordingDirectories(dir)
|
||||
|
||||
const removed = mockRmSync.mock.calls.map((call) => call[0] as string)
|
||||
// Media goes, the event data survives (renamed so the upload phase never
|
||||
// mistakes it for a fresh recording).
|
||||
expect(removed).toContain(
|
||||
'/project/.screenci/My Video [en]/recording.mp4'
|
||||
)
|
||||
expect(removed).not.toContain('/project/.screenci/My Video [en]')
|
||||
expect(mockRenameSync).toHaveBeenCalledWith(
|
||||
'/project/.screenci/My Video [en]/data.json',
|
||||
'/project/.screenci/My Video [en]/last-data.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the anon trial token so one trial spans runs (cap/claim/graduate stay intact)', async () => {
|
||||
const { clearRecordingDirectories } = await import('./cli')
|
||||
const dir = '/project/.screenci'
|
||||
@@ -412,138 +449,6 @@ describe('CLI', () => {
|
||||
// one-record cap and breaking the claim / auto-graduate detection.
|
||||
expect(removed).not.toContain('/project/.screenci/anon-session.json')
|
||||
})
|
||||
|
||||
it('preserves the action-params snapshot so the next run can diff editor overrides', async () => {
|
||||
const { clearRecordingDirectories } = await import('./cli')
|
||||
const dir = '/project/.screenci'
|
||||
mockReaddirSync.mockReturnValue([
|
||||
'My Video [en]',
|
||||
'action-params.json',
|
||||
] as unknown as string[])
|
||||
|
||||
clearRecordingDirectories(dir)
|
||||
|
||||
const removed = mockRmSync.mock.calls.map((call) => call[0] as string)
|
||||
expect(removed).toContain('/project/.screenci/My Video [en]')
|
||||
// Wiping this would lose the previous run's explicit/default provenance,
|
||||
// so override-shadowing warnings could never fire.
|
||||
expect(removed).not.toContain('/project/.screenci/action-params.json')
|
||||
})
|
||||
|
||||
it('preserves the editable-actions snapshot so the next run can diff timing overrides', async () => {
|
||||
const { clearRecordingDirectories } = await import('./cli')
|
||||
const dir = '/project/.screenci'
|
||||
mockReaddirSync.mockReturnValue([
|
||||
'My Video [en]',
|
||||
'editable-actions.json',
|
||||
] as unknown as string[])
|
||||
|
||||
clearRecordingDirectories(dir)
|
||||
|
||||
const removed = mockRmSync.mock.calls.map((call) => call[0] as string)
|
||||
expect(removed).toContain('/project/.screenci/My Video [en]')
|
||||
// Wiping this would lose the previous run's per-field explicit
|
||||
// provenance, so timing-override shadow warnings could never fire.
|
||||
expect(removed).not.toContain('/project/.screenci/editable-actions.json')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchActionOverridesEnv', () => {
|
||||
it('returns no env without a SCREENCI_SECRET (anonymous record works)', async () => {
|
||||
delete process.env.SCREENCI_SECRET
|
||||
const { fetchActionOverridesEnv } = await import('./cli')
|
||||
await expect(
|
||||
fetchActionOverridesEnv('/project/screenci.config.ts', false)
|
||||
).resolves.toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchEditableOverridesEnv', () => {
|
||||
function setupConfig() {
|
||||
process.env.SCREENCI_SECRET = 'sk-test'
|
||||
mockExistsSync.mockImplementation((path: string) =>
|
||||
path.endsWith('screenci.config.ts')
|
||||
)
|
||||
mockReadFile.mockImplementation(async (path: string | URL) => {
|
||||
if (String(path).endsWith('screenci.config.ts')) {
|
||||
return "export default { projectName: 'Test Project' }"
|
||||
}
|
||||
return ''
|
||||
})
|
||||
}
|
||||
|
||||
it('returns no env without a SCREENCI_SECRET (anonymous record works)', async () => {
|
||||
delete process.env.SCREENCI_SECRET
|
||||
const { fetchEditableOverridesEnv } = await import('./cli')
|
||||
await expect(
|
||||
fetchEditableOverridesEnv('/project/screenci.config.ts', false)
|
||||
).resolves.toEqual({})
|
||||
})
|
||||
|
||||
it('injects the fetched timeline-edits map as SCREENCI_TIMELINE_EDITS', async () => {
|
||||
setupConfig()
|
||||
const timelineEdits = {
|
||||
'My video': {
|
||||
version: 2,
|
||||
edits: [
|
||||
{
|
||||
type: 'paramEdit',
|
||||
id: 'param|input|click|getByRole(button)|0',
|
||||
target: { key: 'input|click|getByRole(button)|0' },
|
||||
fields: { moveDuration: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ timelineEdits }),
|
||||
}))
|
||||
)
|
||||
const { fetchEditableOverridesEnv } = await import('./cli')
|
||||
await expect(
|
||||
fetchEditableOverridesEnv('test-fixtures/screenci.config.ts', false)
|
||||
).resolves.toEqual({
|
||||
SCREENCI_TIMELINE_EDITS: JSON.stringify(timelineEdits),
|
||||
})
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns no env when the endpoint fails or has no overrides (never blocks record)', async () => {
|
||||
setupConfig()
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({ ok: false, status: 500 }))
|
||||
)
|
||||
const { fetchEditableOverridesEnv } = await import('./cli')
|
||||
await expect(
|
||||
fetchEditableOverridesEnv('test-fixtures/screenci.config.ts', false)
|
||||
).resolves.toEqual({})
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ timelineEdits: {} }),
|
||||
}))
|
||||
)
|
||||
await expect(
|
||||
fetchEditableOverridesEnv('test-fixtures/screenci.config.ts', false)
|
||||
).resolves.toEqual({})
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new Error('offline')
|
||||
})
|
||||
)
|
||||
await expect(
|
||||
fetchEditableOverridesEnv('test-fixtures/screenci.config.ts', false)
|
||||
).resolves.toEqual({})
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchWebLanguagesEnv', () => {
|
||||
@@ -592,270 +497,6 @@ describe('CLI', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('reportEditableOverrideCollisions', () => {
|
||||
it('prints one line per override shadowing an explicit code value', async () => {
|
||||
const { reportEditableOverrideCollisions } = await import('./cli')
|
||||
const snapshot = {
|
||||
version: 1,
|
||||
videos: {
|
||||
'My video': [
|
||||
{
|
||||
key: 'input|click|getByRole(button)|0',
|
||||
locked: true,
|
||||
lockedFields: ['moveDuration'],
|
||||
defaults: { moveDuration: 400, moveEasing: 'ease-in-out' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify(snapshot))
|
||||
|
||||
const lines: string[] = []
|
||||
reportEditableOverrideCollisions(
|
||||
'/project/.screenci',
|
||||
{
|
||||
SCREENCI_TIMELINE_EDITS: JSON.stringify({
|
||||
'My video': {
|
||||
version: 2,
|
||||
edits: [
|
||||
{
|
||||
type: 'paramEdit',
|
||||
id: 'param|input|click|getByRole(button)|0',
|
||||
target: { key: 'input|click|getByRole(button)|0' },
|
||||
fields: { moveDuration: 250, moveEasing: 'linear' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
(message) => lines.push(message)
|
||||
)
|
||||
|
||||
// Only the explicit code value collides; the defaulted easing does not.
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(lines[0]).toContain('editor override shadows code value')
|
||||
expect(lines[0]).toContain('moveDuration')
|
||||
expect(lines[0]).toContain('code 400')
|
||||
expect(lines[0]).toContain('editor 250')
|
||||
expect(lines[0]).toContain('My video')
|
||||
})
|
||||
|
||||
it('prints nothing without fetched overrides or without a snapshot', async () => {
|
||||
const { reportEditableOverrideCollisions } = await import('./cli')
|
||||
const lines: string[] = []
|
||||
reportEditableOverrideCollisions('/project/.screenci', {}, (message) =>
|
||||
lines.push(message)
|
||||
)
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
reportEditableOverrideCollisions(
|
||||
'/project/.screenci',
|
||||
{
|
||||
SCREENCI_TIMELINE_EDITS: JSON.stringify({
|
||||
'My video': {
|
||||
version: 2,
|
||||
edits: [
|
||||
{
|
||||
type: 'paramEdit',
|
||||
id: 'param|input|click|getByRole(button)|0',
|
||||
target: { key: 'input|click|getByRole(button)|0' },
|
||||
fields: { moveDuration: 250 },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
(message) => lines.push(message)
|
||||
)
|
||||
expect(lines).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('status command', () => {
|
||||
const SNAPSHOT = {
|
||||
version: 1,
|
||||
videos: {
|
||||
'My video': [
|
||||
{
|
||||
selector: "getByRole('button')",
|
||||
method: 'click',
|
||||
occurrence: 0,
|
||||
params: {
|
||||
'move.duration': { value: 400, source: 'explicit' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
function setupProject(overrides: Record<string, unknown>) {
|
||||
process.env.SCREENCI_SECRET = 'sk-test'
|
||||
mockExistsSync.mockImplementation((path: string) => {
|
||||
if (path.endsWith('screenci.config.ts')) return true
|
||||
if (path.endsWith('action-params.json')) return true
|
||||
return false
|
||||
})
|
||||
mockReadFile.mockImplementation(async (path: string | URL) => {
|
||||
if (String(path).endsWith('screenci.config.ts')) {
|
||||
return "export default { projectName: 'Test Project' }"
|
||||
}
|
||||
return ''
|
||||
})
|
||||
mockReadFileSync.mockImplementation((path: string | URL) => {
|
||||
if (String(path).endsWith('action-params.json')) {
|
||||
return JSON.stringify(SNAPSHOT)
|
||||
}
|
||||
return ''
|
||||
})
|
||||
return {
|
||||
fetchActionOverrides: vi.fn(async () => overrides),
|
||||
}
|
||||
}
|
||||
|
||||
it('status reports override kinds against the latest snapshot', async () => {
|
||||
const client = setupProject({
|
||||
'My video': {
|
||||
"getByRole('button')|click|0|move.duration": 250,
|
||||
},
|
||||
})
|
||||
const { printActionStatus } = await import('./cli')
|
||||
const lines: string[] = []
|
||||
await printActionStatus(
|
||||
'test-fixtures/screenci.config.ts',
|
||||
undefined,
|
||||
client,
|
||||
(message) => lines.push(message)
|
||||
)
|
||||
expect(client.fetchActionOverrides).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ projectName: 'Test Project' })
|
||||
)
|
||||
const report = lines.join('\n')
|
||||
expect(report).toContain('Video: My video')
|
||||
expect(report).toContain('override shadows explicit code value')
|
||||
expect(report).toContain('code 400 -> editor 250')
|
||||
})
|
||||
|
||||
it('status reports in-sync when the editor has no overrides', async () => {
|
||||
const client = setupProject({})
|
||||
const { printActionStatus } = await import('./cli')
|
||||
const lines: string[] = []
|
||||
await printActionStatus(
|
||||
'test-fixtures/screenci.config.ts',
|
||||
undefined,
|
||||
client,
|
||||
(message) => lines.push(message)
|
||||
)
|
||||
expect(lines.join('\n')).toContain('in sync')
|
||||
})
|
||||
|
||||
it('status grep filters the timing-overrides block, not just action params', async () => {
|
||||
const client = setupProject({})
|
||||
const timelineEdits = {
|
||||
'My video': {
|
||||
version: 3,
|
||||
edits: [
|
||||
{
|
||||
type: 'paramEdit',
|
||||
id: 'p1',
|
||||
target: { key: 'input|click|getByRole(button)|0' },
|
||||
fields: { sleepBefore: 400 },
|
||||
},
|
||||
],
|
||||
},
|
||||
'Other video': {
|
||||
version: 3,
|
||||
edits: [
|
||||
{
|
||||
type: 'paramEdit',
|
||||
id: 'p2',
|
||||
target: { key: 'delay|||0' },
|
||||
fields: { sleepBefore: 200 },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const fetchEditableOverrides = vi.fn(async () => ({ timelineEdits }))
|
||||
const { printActionStatus } = await import('./cli')
|
||||
const lines: string[] = []
|
||||
await printActionStatus(
|
||||
'test-fixtures/screenci.config.ts',
|
||||
'^My',
|
||||
client,
|
||||
(message) => lines.push(message),
|
||||
fetchEditableOverrides
|
||||
)
|
||||
const report = lines.join('\n')
|
||||
// The grep-excluded video's edits must not leak into the report.
|
||||
expect(report).not.toContain('Other video')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reportActionOverrideCollisions', () => {
|
||||
it('prints one line per override shadowing an explicit code value', async () => {
|
||||
const { reportActionOverrideCollisions } = await import('./cli')
|
||||
const snapshot = {
|
||||
version: 1,
|
||||
videos: {
|
||||
'My video': [
|
||||
{
|
||||
selector: "getByRole('button')",
|
||||
method: 'click',
|
||||
occurrence: 0,
|
||||
params: {
|
||||
'move.duration': { value: 400, source: 'explicit' },
|
||||
'move.easing': { value: 'ease-in-out', source: 'default' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify(snapshot))
|
||||
|
||||
const lines: string[] = []
|
||||
reportActionOverrideCollisions(
|
||||
'/project/.screenci',
|
||||
{
|
||||
SCREENCI_ACTION_OVERRIDES: JSON.stringify({
|
||||
'My video': {
|
||||
"getByRole('button')|click|0|move.duration": 250,
|
||||
"getByRole('button')|click|0|move.easing": 'linear',
|
||||
},
|
||||
}),
|
||||
},
|
||||
(message) => lines.push(message)
|
||||
)
|
||||
|
||||
// Only the explicit code value collides; the defaulted easing does not.
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(lines[0]).toContain('editor override shadows code value')
|
||||
expect(lines[0]).toContain("getByRole('button')")
|
||||
expect(lines[0]).toContain('move.duration')
|
||||
expect(lines[0]).toContain('code 400')
|
||||
expect(lines[0]).toContain('editor 250')
|
||||
expect(lines[0]).toContain('My video')
|
||||
})
|
||||
|
||||
it('prints nothing without fetched overrides or without a snapshot', async () => {
|
||||
const { reportActionOverrideCollisions } = await import('./cli')
|
||||
const lines: string[] = []
|
||||
reportActionOverrideCollisions('/project/.screenci', {}, (message) =>
|
||||
lines.push(message)
|
||||
)
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
reportActionOverrideCollisions(
|
||||
'/project/.screenci',
|
||||
{
|
||||
SCREENCI_ACTION_OVERRIDES: JSON.stringify({
|
||||
'My video': { "getByRole('button')|click|0|move.duration": 250 },
|
||||
}),
|
||||
},
|
||||
(message) => lines.push(message)
|
||||
)
|
||||
expect(lines).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('acquireRecordRunLock', () => {
|
||||
it('refuses a fresh lock whose pid is still alive', async () => {
|
||||
const addSignalListener = vi.fn()
|
||||
@@ -2063,10 +1704,15 @@ describe('CLI', () => {
|
||||
expect(loggerWarnSpy).toHaveBeenCalledWith(
|
||||
'Some recordings failed, uploading successful videos only.'
|
||||
)
|
||||
expect(mockRmSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/.screenci/demo-video'),
|
||||
{ recursive: true, force: true }
|
||||
// Cleanup removes the uploaded media but keeps data.json for the next
|
||||
// dev session's freshness check.
|
||||
expect(mockReaddirSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/.screenci/demo-video')
|
||||
)
|
||||
const removed = mockRmSync.mock.calls.map((call) => String(call[0]))
|
||||
expect(
|
||||
removed.some((path) => path.endsWith('demo-video/data.json'))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('skips upload after partial failure with all-or-nothing policy, then still fails', async () => {
|
||||
@@ -2974,8 +2620,12 @@ describe('CLI', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('removes uploaded recording directories after successful upload', async () => {
|
||||
it('removes uploaded media but keeps data.json after successful upload', async () => {
|
||||
mockReaddir.mockResolvedValue(['demo-video'])
|
||||
mockReaddirSync.mockReturnValue([
|
||||
'data.json',
|
||||
'recording.mp4',
|
||||
] as unknown as string[])
|
||||
mockReadFile.mockImplementation(async (path: string | URL) => {
|
||||
const pathString = String(path)
|
||||
if (pathString.endsWith('package.json')) {
|
||||
@@ -3042,10 +2692,15 @@ describe('CLI', () => {
|
||||
failedVideoMessages: [],
|
||||
plan: null,
|
||||
})
|
||||
expect(mockRmSync).toHaveBeenCalledWith('/repo/.screenci/demo-video', {
|
||||
recursive: true,
|
||||
force: true,
|
||||
})
|
||||
// Media is removed; data.json survives so the next dev session can skip
|
||||
// re-recording when the source is unchanged.
|
||||
expect(mockRmSync).toHaveBeenCalledWith(
|
||||
'/repo/.screenci/demo-video/recording.mp4',
|
||||
{ recursive: true, force: true }
|
||||
)
|
||||
const removed = mockRmSync.mock.calls.map((call) => call[0] as string)
|
||||
expect(removed).not.toContain('/repo/.screenci/demo-video')
|
||||
expect(removed).not.toContain('/repo/.screenci/demo-video/data.json')
|
||||
})
|
||||
|
||||
it('keeps uploaded recording directories when DEBUG=true', async () => {
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import ts from 'typescript'
|
||||
|
||||
const mockExistsSync = vi.fn()
|
||||
const mockRealpathSync = vi.fn((path: string) => path)
|
||||
const mockMkdirSync = vi.fn()
|
||||
const mockRmSync = vi.fn()
|
||||
const mockReaddirSync = vi.fn(() => [] as string[])
|
||||
const mockReadFileSync = vi.fn()
|
||||
const mockWriteFileSync = vi.fn()
|
||||
const mockRenameSync = vi.fn()
|
||||
const mockCreateReadStream = vi.fn()
|
||||
const mockReaddir = vi.fn()
|
||||
const mockReadFile = vi.fn()
|
||||
const mockStat = vi.fn()
|
||||
const mockAppendFile = vi.fn()
|
||||
const mockWriteFile = vi.fn()
|
||||
const mockMkdir = vi.fn()
|
||||
const mockRm = vi.fn()
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
createReadStream: mockCreateReadStream,
|
||||
existsSync: mockExistsSync,
|
||||
realpathSync: mockRealpathSync,
|
||||
mkdirSync: mockMkdirSync,
|
||||
rmSync: mockRmSync,
|
||||
readdirSync: mockReaddirSync,
|
||||
readFileSync: mockReadFileSync,
|
||||
writeFileSync: mockWriteFileSync,
|
||||
renameSync: mockRenameSync,
|
||||
default: {
|
||||
createReadStream: mockCreateReadStream,
|
||||
existsSync: mockExistsSync,
|
||||
realpathSync: mockRealpathSync,
|
||||
mkdirSync: mockMkdirSync,
|
||||
rmSync: mockRmSync,
|
||||
readdirSync: mockReaddirSync,
|
||||
readFileSync: mockReadFileSync,
|
||||
writeFileSync: mockWriteFileSync,
|
||||
renameSync: mockRenameSync,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
appendFile: mockAppendFile,
|
||||
rm: mockRm,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
writeFile: mockWriteFile,
|
||||
mkdir: mockMkdir,
|
||||
default: {
|
||||
appendFile: mockAppendFile,
|
||||
rm: mockRm,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
writeFile: mockWriteFile,
|
||||
mkdir: mockMkdir,
|
||||
},
|
||||
}))
|
||||
|
||||
const SAVE_SELECTOR = "getByRole('button', { name: 'Save' })"
|
||||
// The fixture is already editId-stamped: its stable key IS the slug.
|
||||
const CLICK_KEY = 'click1'
|
||||
|
||||
const SOURCE = [
|
||||
"import { video } from 'screenci'",
|
||||
'',
|
||||
"video('My video', async ({ page }) => {",
|
||||
" await page.getByRole('button', { name: 'Save' }).click({ move: { duration: 400 }, editId: 'click1' })",
|
||||
'})',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const ACTION_SNAPSHOT = {
|
||||
version: 1,
|
||||
videos: {
|
||||
'My video': [
|
||||
{
|
||||
selector: SAVE_SELECTOR,
|
||||
method: 'click',
|
||||
occurrence: 0,
|
||||
editId: 'click1',
|
||||
params: { 'move.duration': { value: 400, source: 'explicit' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const EDITABLE_SNAPSHOT = {
|
||||
version: 1,
|
||||
videos: {
|
||||
'My video': [
|
||||
{
|
||||
key: CLICK_KEY,
|
||||
editId: 'click1',
|
||||
locked: false,
|
||||
defaults: { sleepBefore: 0 },
|
||||
source: { file: '/project/demo.screenci.ts', line: 4 },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
function setupProject(overrides: Record<string, unknown>) {
|
||||
process.env.SCREENCI_SECRET = 'sk-test'
|
||||
mockExistsSync.mockImplementation((path: string) => {
|
||||
if (path.endsWith('screenci.config.ts')) return true
|
||||
if (path.endsWith('action-params.json')) return true
|
||||
if (path.endsWith('editable-actions.json')) return true
|
||||
return false
|
||||
})
|
||||
mockReadFile.mockImplementation(async (path: string | URL) => {
|
||||
if (String(path).endsWith('screenci.config.ts')) {
|
||||
return "export default { projectName: 'Test Project' }"
|
||||
}
|
||||
return ''
|
||||
})
|
||||
mockReadFileSync.mockImplementation((path: string | URL) => {
|
||||
if (String(path).endsWith('action-params.json')) {
|
||||
return JSON.stringify(ACTION_SNAPSHOT)
|
||||
}
|
||||
if (String(path).endsWith('editable-actions.json')) {
|
||||
return JSON.stringify(EDITABLE_SNAPSHOT)
|
||||
}
|
||||
return ''
|
||||
})
|
||||
return {
|
||||
fetchActionOverrides: vi.fn(async () => overrides),
|
||||
}
|
||||
}
|
||||
|
||||
function baseDeps(
|
||||
files: Record<string, string> = {
|
||||
'/project/demo.screenci.ts': SOURCE,
|
||||
}
|
||||
) {
|
||||
const written: Record<string, string> = {}
|
||||
const resets: string[] = []
|
||||
return {
|
||||
written,
|
||||
resets,
|
||||
deps: {
|
||||
fetchEditableOverrides: vi.fn(async () => ({ timelineEdits: {} })),
|
||||
loadTs: () => ts,
|
||||
readFileText: (path: string) => files[path] ?? null,
|
||||
writeFileText: (path: string, text: string) => {
|
||||
written[path] = text
|
||||
},
|
||||
resetVideoEdits: vi.fn(async (_config, videoName: string) => {
|
||||
resets.push(videoName)
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('screenci sync', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
originalEnv = { ...process.env }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv
|
||||
})
|
||||
|
||||
it('dry-runs by default: prints a diff, writes nothing', async () => {
|
||||
const client = setupProject({
|
||||
'My video': { [`${SAVE_SELECTOR}|click|0|move.duration`]: 250 },
|
||||
})
|
||||
const { runSync } = await import('./cli')
|
||||
const { deps, written } = baseDeps()
|
||||
const lines: string[] = []
|
||||
await runSync(
|
||||
'test-fixtures/screenci.config.ts',
|
||||
{},
|
||||
(message) => lines.push(message),
|
||||
{ client, ...deps }
|
||||
)
|
||||
const out = lines.join('\n')
|
||||
expect(out).toContain('Would update /project/demo.screenci.ts')
|
||||
expect(out).toContain('- ')
|
||||
expect(out).toContain('duration: 250')
|
||||
expect(out).toContain('Would apply 1 edit(s):')
|
||||
expect(out).toContain('screenci sync --write')
|
||||
expect(written).toEqual({})
|
||||
})
|
||||
|
||||
it('--write saves the edited file', async () => {
|
||||
const client = setupProject({
|
||||
'My video': { [`${SAVE_SELECTOR}|click|0|move.duration`]: 250 },
|
||||
})
|
||||
const { runSync } = await import('./cli')
|
||||
const { deps, written } = baseDeps()
|
||||
const lines: string[] = []
|
||||
await runSync(
|
||||
'test-fixtures/screenci.config.ts',
|
||||
{ write: true },
|
||||
(message) => lines.push(message),
|
||||
{ client, ...deps }
|
||||
)
|
||||
expect(written['/project/demo.screenci.ts']).toContain(
|
||||
".click({ move: { duration: 250 }, editId: 'click1' })"
|
||||
)
|
||||
expect(lines.join('\n')).toContain('Applied 1 edit(s):')
|
||||
})
|
||||
|
||||
it('--write --reset clears web edits only for fully applied videos', async () => {
|
||||
const client = setupProject({
|
||||
'My video': { [`${SAVE_SELECTOR}|click|0|move.duration`]: 250 },
|
||||
})
|
||||
const { runSync } = await import('./cli')
|
||||
const { deps, resets } = baseDeps()
|
||||
await runSync(
|
||||
'test-fixtures/screenci.config.ts',
|
||||
{ write: true, reset: true },
|
||||
() => {},
|
||||
{ client, ...deps }
|
||||
)
|
||||
expect(resets).toEqual(['My video'])
|
||||
})
|
||||
|
||||
it('keeps web edits when an edit is unappliable (locked section)', async () => {
|
||||
const client = setupProject({
|
||||
'My video': {
|
||||
[`${SAVE_SELECTOR}|click|0|move.duration`]: 250,
|
||||
[`locator('#gone')|click|0|duration`]: 5,
|
||||
},
|
||||
})
|
||||
const { runSync } = await import('./cli')
|
||||
const { deps, resets } = baseDeps()
|
||||
const lines: string[] = []
|
||||
await runSync(
|
||||
'test-fixtures/screenci.config.ts',
|
||||
{ write: true, reset: true },
|
||||
(message) => lines.push(message),
|
||||
{ client, ...deps }
|
||||
)
|
||||
expect(resets).toEqual([])
|
||||
const out = lines.join('\n')
|
||||
expect(out).toContain('could not be applied')
|
||||
expect(out).toContain('No video had all of its edits applied')
|
||||
})
|
||||
|
||||
it('applies timeline sleepBefore edits from the editable snapshot', async () => {
|
||||
const client = setupProject({})
|
||||
const { runSync } = await import('./cli')
|
||||
const { deps, written } = baseDeps()
|
||||
deps.fetchEditableOverrides = vi.fn(async () => ({
|
||||
timelineEdits: {
|
||||
'My video': {
|
||||
version: 2,
|
||||
edits: [
|
||||
{
|
||||
type: 'paramEdit',
|
||||
id: 'p1',
|
||||
target: { key: CLICK_KEY },
|
||||
fields: { sleepBefore: 500 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}))
|
||||
await runSync(
|
||||
'test-fixtures/screenci.config.ts',
|
||||
{ write: true },
|
||||
() => {},
|
||||
{ client, ...deps }
|
||||
)
|
||||
expect(written['/project/demo.screenci.ts']).toContain(
|
||||
'await page.waitForTimeout(500)'
|
||||
)
|
||||
})
|
||||
|
||||
it('stamps missing editIds even without editor edits', async () => {
|
||||
const unstampedSource = SOURCE.replace(", editId: 'click1'", '')
|
||||
const client = setupProject({})
|
||||
// Replace the editable snapshot with an unstamped entry.
|
||||
mockReadFileSync.mockImplementation((path: string | URL) => {
|
||||
if (String(path).endsWith('action-params.json')) {
|
||||
return JSON.stringify(ACTION_SNAPSHOT)
|
||||
}
|
||||
if (String(path).endsWith('editable-actions.json')) {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
videos: {
|
||||
'My video': [
|
||||
{
|
||||
key: 'input|click|getByRole(button, name=Save)|0',
|
||||
locked: false,
|
||||
defaults: { sleepBefore: 0 },
|
||||
source: { file: '/project/demo.screenci.ts', line: 4 },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
return ''
|
||||
})
|
||||
const { runSync } = await import('./cli')
|
||||
const { deps, written } = baseDeps({
|
||||
'/project/demo.screenci.ts': unstampedSource,
|
||||
})
|
||||
const lines: string[] = []
|
||||
await runSync(
|
||||
'test-fixtures/screenci.config.ts',
|
||||
{ write: true },
|
||||
(message) => lines.push(message),
|
||||
{ client, ...deps }
|
||||
)
|
||||
expect(written['/project/demo.screenci.ts']).toContain(
|
||||
".click({ move: { duration: 400 }, editId: 'click1' })"
|
||||
)
|
||||
expect(lines.join('\n')).toContain('Stamped editIds for 1 action(s):')
|
||||
// Counters persisted via write-then-rename.
|
||||
expect(mockWriteFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('edit-ids.json'),
|
||||
expect.stringContaining('"click": 1')
|
||||
)
|
||||
})
|
||||
|
||||
it('reports nothing to sync when the editor has no differing edits', async () => {
|
||||
const client = setupProject({})
|
||||
const { runSync } = await import('./cli')
|
||||
const { deps } = baseDeps()
|
||||
const lines: string[] = []
|
||||
await runSync(
|
||||
'test-fixtures/screenci.config.ts',
|
||||
{},
|
||||
(message) => lines.push(message),
|
||||
{ client, ...deps }
|
||||
)
|
||||
expect(lines.join('\n')).toContain('Nothing to sync')
|
||||
})
|
||||
|
||||
it('auto-sync applies once, dedupes unchanged state, and resets applied videos', async () => {
|
||||
// Mutable editor state: after the reset the backend has no overrides.
|
||||
let overrides: Record<string, unknown> = {
|
||||
'My video': { [`${SAVE_SELECTOR}|click|0|move.duration`]: 250 },
|
||||
}
|
||||
const client = setupProject({})
|
||||
client.fetchActionOverrides = vi.fn(async () => overrides)
|
||||
const { runDevAutoSync } = await import('./cli')
|
||||
const { deps, written, resets } = baseDeps()
|
||||
deps.resetVideoEdits = vi.fn(async (_config, videoName: string) => {
|
||||
resets.push(videoName)
|
||||
overrides = {}
|
||||
})
|
||||
const controller = { stopped: false }
|
||||
let ticks = 0
|
||||
const lines: string[] = []
|
||||
await runDevAutoSync(
|
||||
'test-fixtures/screenci.config.ts',
|
||||
controller,
|
||||
() => false,
|
||||
(message) => lines.push(message),
|
||||
{ client, ...deps },
|
||||
1,
|
||||
async () => {
|
||||
ticks += 1
|
||||
if (ticks > 4) controller.stopped = true
|
||||
}
|
||||
)
|
||||
expect(written['/project/demo.screenci.ts']).toContain(
|
||||
".click({ move: { duration: 250 }, editId: 'click1' })"
|
||||
)
|
||||
expect(resets).toEqual(['My video'])
|
||||
const out = lines.join('\n')
|
||||
expect(out).toContain('auto-sync: [My video]')
|
||||
expect(out).toContain('cleared web timeline edits')
|
||||
// The edit applied exactly once across the ticks.
|
||||
expect(
|
||||
lines.filter((line) => line.includes('set move.duration'))
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('auto-sync skips ticks while a record is running', async () => {
|
||||
const client = setupProject({
|
||||
'My video': { [`${SAVE_SELECTOR}|click|0|move.duration`]: 250 },
|
||||
})
|
||||
const { runDevAutoSync } = await import('./cli')
|
||||
const { deps, written } = baseDeps()
|
||||
const controller = { stopped: false }
|
||||
let ticks = 0
|
||||
await runDevAutoSync(
|
||||
'test-fixtures/screenci.config.ts',
|
||||
controller,
|
||||
() => true,
|
||||
() => {},
|
||||
{ client, ...deps },
|
||||
1,
|
||||
async () => {
|
||||
ticks += 1
|
||||
if (ticks > 3) controller.stopped = true
|
||||
}
|
||||
)
|
||||
expect(written).toEqual({})
|
||||
expect(client.fetchActionOverrides).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies nothing when typescript is unavailable', async () => {
|
||||
const client = setupProject({
|
||||
'My video': { [`${SAVE_SELECTOR}|click|0|move.duration`]: 250 },
|
||||
})
|
||||
const { runSync } = await import('./cli')
|
||||
const { deps, written } = baseDeps()
|
||||
const lines: string[] = []
|
||||
await runSync(
|
||||
'test-fixtures/screenci.config.ts',
|
||||
{},
|
||||
(message) => lines.push(message),
|
||||
{ client, ...deps, loadTs: () => null }
|
||||
)
|
||||
const out = lines.join('\n')
|
||||
expect(out).toContain('Could not load the typescript module')
|
||||
expect(written).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -180,8 +180,8 @@ fire along the arc, not just in the rendered video.
|
||||
|
||||
The curve is also editable visually: in the web editor, select an interaction
|
||||
(click it in the timeline, or click near the cursor in the preview) and drag
|
||||
the two bezier handles over the video. The edit applies on the next record,
|
||||
and `screenci sync` writes it back into the call as `move.curve`.
|
||||
the two bezier handles over the video. With `screenci dev` connected the edit
|
||||
is written into the call as `move.curve` and applies on the next record.
|
||||
|
||||
### fill and pressSequentially
|
||||
|
||||
|
||||
+22
-90
@@ -17,8 +17,6 @@ commands from inside your `screenci/` project directory. Most commands resolve
|
||||
| `screenci record [playwrightArgs]` | Record videos and upload results when configured |
|
||||
| `screenci dev` | Connect this machine to the editor for on-demand records |
|
||||
| `screenci info` | Print the last record run's URLs and render status |
|
||||
| `screenci status` | Compare Editor action edits with the latest recorded run |
|
||||
| `screenci sync` | Apply Editor edits to the test sources (static analysis) |
|
||||
| `screenci make-public <videoId>` | Enable public delivery for a video |
|
||||
| `screenci make-private <videoId>` | Disable public delivery for a video |
|
||||
| `screenci delete <videoId>` | Permanently delete a video and its renders |
|
||||
@@ -355,17 +353,30 @@ Options:
|
||||
- `-c, --config <path>`: path to the ScreenCI config file.
|
||||
- `--token <token>`: personal dev token. Defaults to `SCREENCI_DEV_TOKEN` from
|
||||
your project env file.
|
||||
- `--sync`: auto-apply web editor edits to the recording sources while
|
||||
listening. Every few seconds the listener checks for new edits, stamps
|
||||
missing `editId` slugs (see [`screenci sync`](#screenci-sync)), and applies
|
||||
what sync can apply mechanically; a video whose
|
||||
pending edits were all applied gets its web edits cleared automatically, so
|
||||
the next record runs purely from code. Edits whose section is locked (an
|
||||
unstamped action, a loop repeat, or a call inside control flow) are left in
|
||||
the editor and reported in the log. Auto-sync pauses while a triggered
|
||||
record is running.
|
||||
- `-g, --grep <pattern>`: only manage videos whose title matches this pattern
|
||||
(the same filter as Playwright's `--grep`). The startup check and any
|
||||
startup records are limited to the matching videos.
|
||||
- `--force-record`: re-record every managed video at startup even when the
|
||||
kept recordings are up to date.
|
||||
- `--record-kill-window <seconds>`: while connected, a running record younger
|
||||
than this is killed and replaced when a newer record request arrives; an
|
||||
older one finishes first and the new request runs right after (only the
|
||||
latest queued request is kept). Default: 10.
|
||||
- `-v, --verbose`: verbose output.
|
||||
|
||||
Startup check: `screenci dev` keeps each recording's `data.json` on disk after
|
||||
upload, along with a hash of the test source file. On startup it compares that
|
||||
hash with the current source and verifies every editable action carries an
|
||||
`editId`. Up-to-date videos skip recording entirely; anything stale gets its
|
||||
missing `editId` slugs stamped into the source and is re-recorded as a preview
|
||||
(no render) before the session starts serving the editor.
|
||||
|
||||
Editor codegen: while connected, every edit made in the web editor is sent to
|
||||
this machine as a codegen request and written directly into the
|
||||
`.screenci.ts` source (the code is the single source of truth). Edits that
|
||||
change recorded behavior additionally trigger an automatic preview record.
|
||||
An edit the codegen cannot apply is reported back and the editor reverts it.
|
||||
|
||||
Setup:
|
||||
|
||||
1. Create a personal dev token on the Secrets page in the app (one token per
|
||||
@@ -517,85 +528,6 @@ reaches `finished` (or gate your pipeline on `failed`).
|
||||
not fail. It simply omits `latestRecordId` and the `latestRecord` fields and
|
||||
prints the project-wide listing with `static` URLs only.
|
||||
|
||||
## `screenci status`
|
||||
|
||||
Fetches the web Editor's current action-parameter edits and compares them with
|
||||
the latest recorded run's snapshot (`.screenci/action-params.json`). Reports
|
||||
per video which overrides shadow explicitly code-set values, which change
|
||||
defaulted values, which match code (no effect), and which are stale because the
|
||||
action no longer exists in the latest recording.
|
||||
|
||||
Options:
|
||||
|
||||
- `-c, --config <path>`: path to `screenci.config.ts`
|
||||
- `-g, --grep <regex>`: only include videos whose name matches the regular
|
||||
expression (same semantics as Playwright's `--grep`)
|
||||
|
||||
Requires `SCREENCI_SECRET`. See
|
||||
[Action parameter tracking and overrides](/docs/editor#action-parameter-tracking-and-overrides)
|
||||
for the full model.
|
||||
|
||||
## `screenci sync`
|
||||
|
||||
Applies the web Editor's edits directly to your `.screenci.ts` sources using
|
||||
static analysis (the TypeScript parser), no agent involved.
|
||||
|
||||
Before applying anything, `sync` stamps a stable identity onto every recorded
|
||||
action that lacks one: a human-readable `editId` slug (`click1`, `fill2`,
|
||||
`autoZoom1`) inserted into the call's options. From then on edits locate their
|
||||
call site by that exact slug, so refactors, moved lines, and locator changes
|
||||
never break the mapping. Slugs are allocated from `.screenci/edit-ids.json`
|
||||
(commit it), numbers are never reused, and stamped ids are never removed.
|
||||
Actions that run inside a loop cannot carry a per-execution identity and are
|
||||
skipped (their edits stay web-runtime-only).
|
||||
|
||||
It can:
|
||||
|
||||
- change, add, or remove explicit action options (for example
|
||||
`move.duration` on a `.click(...)`)
|
||||
- insert `await page.waitForTimeout(...)` for start-time edits
|
||||
- place web-authored effects into the linear timeline: a narration cue /
|
||||
overlay / audio start in the gap after an action, an `autoZoom(...)` bracket
|
||||
over a run of interactions, a `hide(...)` / `speed(...)` / `time(...)` span,
|
||||
or an instant `moveNarration(...)` / `resizeRecording(...)` /
|
||||
`setBackground(...)` point (with the needed imports and gap `waitForTimeout`
|
||||
sleeps)
|
||||
- codify Editor render/record option edits into the video's builder chain,
|
||||
updating an existing `video.renderOptions({...})` / `video.recordOptions({...})`
|
||||
call in place (merging the changed keys) or inserting one before the video's
|
||||
`('Name', fn)` call when absent
|
||||
- apply editId renames made in the Editor
|
||||
|
||||
Re-running `sync` is safe. Applying the same edits again is a no-op: an effect
|
||||
already placed after its action is updated in place, never stacked, so the file
|
||||
stops changing once code and Editor agree. Changing only an effect's lead sleep
|
||||
rewrites the existing `waitForTimeout(...)` rather than adding another. And when
|
||||
an effect is removed in the Editor, `sync` deletes the call it authored and
|
||||
re-coalesces the gap sleep it split, so no orphaned "ghost" sleep is left
|
||||
behind. This cleanup runs only for the point effects it can identify
|
||||
unambiguously (narration cues, overlays, audio starts, and
|
||||
`moveNarration`/`resizeRecording`/`setBackground` points); a removed
|
||||
`autoZoom`/`hide`/`speed`/`time` wrap is left in place for you to delete by hand
|
||||
rather than risk unwrapping code you may have since edited.
|
||||
|
||||
Every edit locates its call site by the exact `editId` slug: there is no
|
||||
heuristic matching, so an applied edit is never a guess. An edit either applies
|
||||
by editId or its section is locked and reported as an unappliable count to edit
|
||||
by hand: an action that has not been stamped yet (record once, then sync), a
|
||||
loop execution, or a call inside control flow (`if`/`for`/...). There is no
|
||||
agent-prompt fallback.
|
||||
|
||||
By default `screenci sync` is a dry run that prints per-file diffs. Options:
|
||||
|
||||
- `-w, --write`: save the changes to disk
|
||||
- `--reset` (with `--write`): clear the web timeline edits of every video
|
||||
whose pending edits were all applied (like `screenci reset-web-edits`, per
|
||||
video)
|
||||
- `-c, --config` and `-g, --grep` as in `screenci status`
|
||||
|
||||
Requires `SCREENCI_SECRET` and a resolvable `typescript` installation (any
|
||||
ScreenCI project has one).
|
||||
|
||||
## `screenci make-public <videoId>`
|
||||
|
||||
<Tabs syncKey="package-manager">
|
||||
|
||||
+75
-165
@@ -72,7 +72,7 @@ video.recordOptions({ fps: 30 })
|
||||
- [how render and record options combine with web edits](#editor-render-and-record-options)
|
||||
- [how to manage languages from Editor](#editor-languages-from-code)
|
||||
- [how to place effects from code](#effects-in-code-block-wrappers-and-gap-sleeps)
|
||||
- [how web edits move into code with an agent](#the-agentic-loop)
|
||||
- [how web edits reach code](#how-edits-reach-code)
|
||||
- [how action parameters are tracked and overridden](#action-parameter-tracking-and-overrides)
|
||||
- [how to migrate from the removed `editable()` helper](#migrating-from-editable)
|
||||
|
||||
@@ -329,18 +329,17 @@ the web timeline and applied on the next record, without touching code.
|
||||
Every interaction is editable from the web, whether its values come from
|
||||
package defaults or from explicit options in code. Its identity is the
|
||||
captured locator description (for example `getByRole(button, name=Save)`)
|
||||
plus its position on the timeline. Explicit code options do not block edits:
|
||||
the editor shows those fields with a `code` marker, and saving an edit over
|
||||
one shows a note that it shadows the code value. At the next record the CLI
|
||||
prints a matching warning (`editor override shadows code value: ...`), and
|
||||
`screenci status` lists every shadowing edit so you can move it into code or
|
||||
clear it.
|
||||
plus its position on the timeline. Code is the single source of truth: while
|
||||
`screenci dev` is connected, each edit you save in the editor is codegen'd
|
||||
straight into the `.screenci.ts` sources (keyed by the action's `editId`
|
||||
slug), so the code always shows the current values and the next record simply
|
||||
runs from code.
|
||||
|
||||
Cursor-move fields (`move.duration`/`move.speed`, `move.easing`, `move.curve`,
|
||||
`move.curviness`, `move.delayAfter`), action durations, and pre-action pauses
|
||||
all sync back into code: `screenci sync` writes each edited value as the
|
||||
matching option on the `editId`-stamped call. The cursor path's curve can be
|
||||
edited visually in the preview by dragging its bezier handles.
|
||||
are all written as the matching option on the `editId`-stamped call. The
|
||||
cursor path's curve can be edited visually in the preview by dragging its
|
||||
bezier handles.
|
||||
|
||||
Manual `zoomTo(...)` calls and `scrollIntoViewIfNeeded()` also appear on the
|
||||
editor's "Zooms & scrolls" row with editable `easing`, `duration`, `amount`,
|
||||
@@ -357,7 +356,7 @@ await speed('intro-speedup', async () => { ... })
|
||||
// Unnamed editable block, identified by its timeline position.
|
||||
await speed(async () => { ... })
|
||||
|
||||
// Explicit: the multiplier comes from code (a web edit shadows it and warns).
|
||||
// Explicit: the multiplier comes from code (a web edit rewrites this call).
|
||||
await speed(3, async () => { ... })
|
||||
|
||||
// Bare autoZoom stays fully web-editable, starting from the package defaults.
|
||||
@@ -366,7 +365,7 @@ await autoZoom(async () => { ... })
|
||||
// Web-editable pause: defaults to 0ms until edited in the web timeline.
|
||||
await page.waitForTimeout()
|
||||
|
||||
// Explicit pause: the duration comes from code (a web edit shadows it).
|
||||
// Explicit pause: the duration comes from code (a web edit rewrites it).
|
||||
await page.waitForTimeout(500)
|
||||
```
|
||||
|
||||
@@ -376,12 +375,10 @@ moving, pushing the action later on the timeline. In the editor, dragging a
|
||||
bar's left edge sets it, and the pause shows as a leading "sleep" part of the
|
||||
bar.
|
||||
|
||||
Before `screenci record` (and `screenci test --mock-record`) the CLI fetches
|
||||
the saved web edits and applies them to the run. Plain `screenci test` skips
|
||||
timings entirely, so it neither fetches nor applies them. After each upload the
|
||||
stored timeline is reconciled against what was actually recorded: new actions
|
||||
appear in place, removed actions disappear, and edits whose action vanished are
|
||||
kept as stale entries in the editor for cleanup instead of being dropped.
|
||||
Recordings always run purely from code: nothing is fetched or overridden at
|
||||
record time. After each upload the timeline is reconciled against what was
|
||||
actually recorded, so new actions appear in place and removed actions
|
||||
disappear.
|
||||
|
||||
## Web-authored events
|
||||
|
||||
@@ -392,11 +389,10 @@ moves/resizes, recording resize/hide/show). Interactions are different on
|
||||
purpose: a click or tap always stays where the test code performed it, and
|
||||
only its parameters (durations, sleeps) are editable.
|
||||
|
||||
Everything the timeline adds is stored as one unified edit keyed to a call
|
||||
position; there is no separate legacy path. A newly added event appears
|
||||
immediately on the timeline as a pending item (a dashed, dimmed bar) that
|
||||
reads "applies at the next record", so you can see and remove it before
|
||||
re-recording.
|
||||
Everything the timeline adds is one unified edit record keyed to a call
|
||||
position, and it is codegen'd into the sources the moment it is saved (via
|
||||
the connected `screenci dev` session). A newly added event appears on the
|
||||
timeline as a pending item until the next record confirms it.
|
||||
|
||||
Every web-placed or web-moved event is positioned by **call position**: which
|
||||
editable action it sits after (or, for a span, the run of actions it brackets),
|
||||
@@ -417,11 +413,11 @@ that click by making it the action the event sits before, with a `waitForTimeout
|
||||
gap. There is no free offset field: everything lands in a gap between known
|
||||
actions or brackets a known run of actions.
|
||||
|
||||
At the next record the CLI fetches the stored edits and the SDK plays them in
|
||||
call order. An action that no longer exists (its editId vanished from the
|
||||
latest recording) never fails the recording and is never dropped silently: the
|
||||
edit is reported as `skipped` with a reason. The editor shows these outcomes on
|
||||
the timeline, and `screenci status` reports them with a fix suggestion.
|
||||
Each edit is applied to code the moment it is saved: the dev session locates
|
||||
the call site by editId and writes the call-position statement into the
|
||||
source. An edit that cannot be applied (its editId vanished, or the section is
|
||||
locked) fails the codegen request and the editor reverts the optimistic value
|
||||
instead of dropping it silently.
|
||||
|
||||
## Effects in code: block wrappers and gap sleeps
|
||||
|
||||
@@ -491,64 +487,54 @@ await narration.stats()
|
||||
Rule of thumb: gaps are `waitForTimeout` sleeps, render-time spans and zooms
|
||||
are block wrappers over the interactions they cover, and narration/overlay
|
||||
cues are plain calls placed where you want them in call order. The web editor
|
||||
shows this same linear timeline, and `screenci sync` writes these
|
||||
call-position statements back into code, keyed by each action's `editId`.
|
||||
shows this same linear timeline, and editor edits are codegen'd into these
|
||||
same call-position statements, keyed by each action's `editId`.
|
||||
|
||||
## The agentic loop
|
||||
## How edits reach code
|
||||
|
||||
Web edits and code stay in sync through a loop designed for coding agents:
|
||||
Code is the single source of truth, and the loop is a single step:
|
||||
|
||||
1. **Edit in the web timeline.** Drags and added events are stored as
|
||||
call-position edits (which action the event sits after, or the run it
|
||||
brackets, plus any gap sleeps), each keyed to a stable `editId`.
|
||||
2. **Record.** `screenci record` fetches the edits, applies them, and prints
|
||||
the override report; every edit ends as applied or skipped with a reason,
|
||||
in the logs and in the editor.
|
||||
3. **Check drift.** `screenci status` lists edits that shadow explicit code
|
||||
values and stale edits whose action vanished.
|
||||
4. **Codify.** `screenci sync` applies the edits directly to the `.screenci.ts`
|
||||
sources via static analysis (dry-run by default, `--write` to save). Each
|
||||
edit locates its call site by the exact `editId` slug and writes the
|
||||
call-position statement: a `narration.x()` / overlay / presentation call
|
||||
(with a `waitForTimeout` gap) into the right spot, or an
|
||||
`autoZoom` / `hide` / `speed` / `time` block bracketing the right run of
|
||||
interactions. There is no agent-prompt fallback: an edit either applies by
|
||||
editId or its section is locked (a loop or branch) and reported as an
|
||||
unappliable count.
|
||||
5. **Clear the web layer.** `screenci reset-web-edits` removes the codified
|
||||
edits so the next record runs purely from code, and the loop is closed
|
||||
(`screenci sync --write --reset` does this automatically for videos whose
|
||||
edits were all applied).
|
||||
1. **Connect.** Run `screenci dev` in the project. The startup handshake
|
||||
brings every managed video up to date, then the machine serves the editor.
|
||||
2. **Edit in the web timeline.** Each saved edit arrives over the dev channel
|
||||
as a codegen request and is written into the `.screenci.ts` sources
|
||||
immediately, via static analysis (the TypeScript parser), no agent
|
||||
involved. Each edit locates its call site by the exact `editId` slug and
|
||||
writes the call-position statement: an option value on the stamped call, a
|
||||
`narration.x()` / overlay / presentation call (with a `waitForTimeout`
|
||||
gap), or an `autoZoom` / `hide` / `speed` / `time` block bracketing the
|
||||
right run of interactions. An edit either applies by editId or its section
|
||||
is locked (a loop or branch) and the request fails, reverting the edit in
|
||||
the editor.
|
||||
3. **Record.** Recordings always run purely from code, so what you see on the
|
||||
next record is exactly what the sources say.
|
||||
|
||||
Because the web timeline and code share one linear model, codifying an edit
|
||||
inserts the same call you would have written by hand, and the next recording's
|
||||
override report confirms the code now produces the same result.
|
||||
Because the web timeline and code share one linear model, a codegen'd edit
|
||||
inserts the same call you would have written by hand.
|
||||
|
||||
## Action identity: editId
|
||||
|
||||
Every editable action can carry a stable, human-readable identity slug in
|
||||
code, e.g. `.click({ editId: 'click1' })` or
|
||||
`autoZoom(fn, { editId: 'autoZoom1' })`. `screenci sync` (and `screenci dev
|
||||
--sync`) stamps missing slugs automatically after a recording, allocating
|
||||
numbers from `.screenci/edit-ids.json` (commit it; numbers are never reused
|
||||
and stamped ids are never removed). With an editId, the action's stable key
|
||||
IS the slug: edits keep matching across re-records even after refactors, moved
|
||||
lines, or locator changes, and `screenci sync`
|
||||
locates the call site by the exact slug instead of heuristics.
|
||||
`autoZoom(fn, { editId: 'autoZoom1' })`. The `screenci dev` startup handshake
|
||||
stamps missing slugs automatically after a recording, allocating numbers from
|
||||
`.screenci/edit-ids.json` (commit it; numbers are never reused and stamped ids
|
||||
are never removed). With an editId, the action's stable key IS the slug: edits
|
||||
keep matching across re-records even after refactors, moved lines, or locator
|
||||
changes, and codegen locates the call site by the exact slug instead of
|
||||
heuristics.
|
||||
|
||||
The slug is the action's display name on the editor timeline, and it can be
|
||||
renamed there: the rename is stored as a web edit and `screenci sync` applies
|
||||
it by replacing the slug's string literal in code. Nothing goes stale in
|
||||
between because the recorded slug keeps matching until the rename is
|
||||
codified.
|
||||
renamed there: the rename is codegen'd by replacing the slug's string literal
|
||||
in code.
|
||||
|
||||
editId is optional until edits need to reach code. Actions without one keep
|
||||
the matcher-based identity (locator description + occurrence) for display and
|
||||
record-time overrides, but `screenci sync` never guesses at their call sites:
|
||||
their edits stay web-runtime-only until a record plus sync stamps them. An
|
||||
action that executes more than once in a recording (a loop) gets keys like
|
||||
`click1#1` for the repeat executions; those sit in a locked section that
|
||||
cannot be expressed as code options and stay web-runtime-only.
|
||||
the matcher-based identity (locator description + occurrence) for display, but
|
||||
codegen never guesses at their call sites: their edits cannot apply until the
|
||||
dev startup handshake stamps them. An action that executes more than once in a
|
||||
recording (a loop) gets keys like `click1#1` for the repeat executions; those
|
||||
sit in a locked section that cannot be expressed as code options and are not
|
||||
editable.
|
||||
|
||||
## What is editable from the web
|
||||
|
||||
@@ -576,47 +562,17 @@ remaps, background changes, narration-box changes, and recording changes,
|
||||
each placed by call position (after a known action, or bracketing a run of
|
||||
actions) with any gap expressed as a `waitForTimeout` sleep.
|
||||
|
||||
## Resetting web edits
|
||||
## Undoing web edits
|
||||
|
||||
- In the editor: the "pending web edits" strip has a **Reset all** button
|
||||
(clears timing overrides and authored events for the video).
|
||||
- From the CLI: `screenci reset-web-edits [--video <name>]` clears the whole
|
||||
project (or one video), so the next record runs purely from code.
|
||||
- `screenci sync --write` moves web edits INTO code, locating each call site by
|
||||
its `editId` slug and writing the call-position statement. After codifying,
|
||||
clear the web layer with `reset-web-edits`.
|
||||
|
||||
## The override report
|
||||
|
||||
Every record run produces an override report: one line per web edit the run
|
||||
tried to apply, with its outcome. Skips and edits that shadow explicit code
|
||||
values are always logged; a summary line closes each video:
|
||||
|
||||
```
|
||||
[screenci overrides] applied event hide after=submit +250ms h_ab12
|
||||
[screenci overrides] SKIPPED event narrationCue after=intro reason=editIdMissing:intro n_9
|
||||
[screenci overrides] My video: 3 applied, 1 skipped
|
||||
```
|
||||
|
||||
The same items are embedded into the uploaded recording data
|
||||
(`overrideReport` in `data.json`), so the editor can show whether each edit
|
||||
was applied or skipped and why. A fetch failure before the run also warns
|
||||
loudly: a recording never silently ignores your saved edits.
|
||||
Edits live in your sources, so undoing one is a code change: revert the file
|
||||
in git (or edit it by hand) and record again. There is no separate web edit
|
||||
layer to reset.
|
||||
|
||||
## Debugging overrides
|
||||
|
||||
Set `SCREENCI_DEBUG_OVERRIDES=1` when running `screenci record` to trace the
|
||||
whole loop: the CLI first dumps every override set fetched from the backend
|
||||
(timing overrides, action parameters, timeline edits, text values, record
|
||||
options, web-added languages), then the run logs each value again at the
|
||||
moment it is applied (applied lines join the always-on skip/fallback lines):
|
||||
|
||||
```
|
||||
[screenci debug] Editor timing overrides:
|
||||
{ "My video": [ { "key": "input|click|locator(#go)|0", "values": { "moveDuration": 150 } } ] }
|
||||
[screenci debug] editor override applied: input|click|locator(#go)|0 moveDuration: 900 -> 150
|
||||
[screenci overrides] applied event speed from=next until=confirm s_3
|
||||
```
|
||||
Set `SCREENCI_DEBUG_OVERRIDES=1` when running `screenci record` to dump the
|
||||
Studio-owned override sets fetched from the backend before the run (text
|
||||
values, record options, web-added languages).
|
||||
|
||||
## Editor languages from code
|
||||
|
||||
@@ -693,64 +649,18 @@ Every instrumented Playwright action (`click`, `fill`, `pressSequentially`,
|
||||
`scrollIntoViewIfNeeded`) records which option values it used, for example
|
||||
`move.duration`, `move.speed`, `move.easing`, `move.delayAfter`, `position`,
|
||||
`noWaitAfter`, `duration`, and `dragSteps`, and whether each value was set
|
||||
explicitly at the call site or came from a default. This provenance is:
|
||||
explicitly at the call site or came from a default. This provenance is written
|
||||
into the uploaded recording data (`actionParams` in `data.json`), so the
|
||||
backend and Editor can present the parameters for editing.
|
||||
|
||||
- written into the uploaded recording data (`actionParams` in `data.json`), so
|
||||
the backend and Editor can present the parameters for editing;
|
||||
- snapshotted to `.screenci/action-params.json`, which is never wiped between
|
||||
runs.
|
||||
|
||||
Editor overrides can target both kinds of values: an option you set explicitly
|
||||
in code and one that fell back to a default. Overriding a default is the quiet,
|
||||
normal case; the Editor shows a small warning marker only when your edit
|
||||
shadows an explicitly code-set value.
|
||||
|
||||
At the start of `screenci record`, editor overrides fetched from the backend
|
||||
are compared against the latest local snapshot, and an info line is printed for
|
||||
each override that shadows an explicitly code-set value:
|
||||
|
||||
```
|
||||
[screenci] editor override shadows code value: <selector> <method> <optionPath>: code <value> -> editor <value> (video: <name>)
|
||||
```
|
||||
|
||||
During recording, editor overrides are applied to actions. Only an override
|
||||
that actually changes the value the recording runs with is reported (an
|
||||
override that restates the code value is a no-op):
|
||||
|
||||
```
|
||||
[screenci] editor override: <selector> <method> <optionPath>: <used> (code: <codeValue>, explicit|default)
|
||||
```
|
||||
|
||||
When an override changed a value, the recording's `actionParams` entry carries
|
||||
the actually used value in a `used` field next to the code value, so the Editor
|
||||
can update its own copy of the options from what the recording really ran with.
|
||||
Editing a parameter in the web editor writes it into the call site as an
|
||||
explicit option (via the connected `screenci dev` session), whether the value
|
||||
previously came from code or from a default. The recording always runs with
|
||||
whatever the code says.
|
||||
|
||||
The SDK also exports `ACTION_PARAM_DEFAULTS`, the default value of every
|
||||
tracked option per action method, so integrations can tell an override that
|
||||
merely restates the default from a real change and offer "reset to default".
|
||||
|
||||
### Checking and syncing: `status` and `sync`
|
||||
|
||||
Two commands keep code and Editor edits from drifting apart:
|
||||
|
||||
- `screenci status` fetches the Editor's current edits and compares them with
|
||||
the latest recorded run: which overrides shadow explicit code values, which
|
||||
change defaults, which are stale (the action no longer exists in the latest
|
||||
recording, usually because the code changed).
|
||||
- `screenci sync` applies the Editor's edits directly to the `.screenci.ts`
|
||||
sources (dry-run by default, `--write` to save). It locates each call site by
|
||||
the exact `editId` slug and writes the change in place: action-parameter
|
||||
edits set or remove option values, and added effects become call-position
|
||||
statements (a `narration.x()` / overlay / presentation call with a
|
||||
`waitForTimeout` gap, or an `autoZoom` / `hide` / `speed` / `time` block
|
||||
bracketing the right run of interactions). There is no agent-prompt path: an
|
||||
edit either applies by editId or its section is locked and reported as an
|
||||
unappliable count. Render and record options the Editor holds are codified
|
||||
into `video.renderOptions(...)` / `video.recordOptions(...)`; record options
|
||||
are captured at record time, so re-record after codifying them.
|
||||
|
||||
Both accept `-g, --grep <regex>` to filter videos by name (the same semantics
|
||||
as Playwright's `--grep`) and `-c, --config <path>`.
|
||||
tracked option per action method, so integrations can tell an edit that merely
|
||||
restates the default from a real change and offer "reset to default".
|
||||
|
||||
## Migrating from `editable()`
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createActionOverridesClient } from './actionOverridesClient.js'
|
||||
|
||||
const INPUT = {
|
||||
apiUrl: 'https://api.example.test',
|
||||
secret: 'sk-test',
|
||||
projectName: 'My Project',
|
||||
}
|
||||
|
||||
function fakeFetch(status: number, body?: unknown): typeof fetch {
|
||||
return vi.fn(async () => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
})) as unknown as typeof fetch
|
||||
}
|
||||
|
||||
describe('createActionOverridesClient', () => {
|
||||
it('fetches /cli/action-overrides with the project and secret', async () => {
|
||||
const overrides = {
|
||||
'My video': { "getByRole('button')|click|0|move.duration": 250 },
|
||||
}
|
||||
const fetchImpl = fakeFetch(200, { overrides })
|
||||
const client = createActionOverridesClient(fetchImpl)
|
||||
await expect(client.fetchActionOverrides(INPUT)).resolves.toEqual(overrides)
|
||||
const [url, init] = (fetchImpl as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe(
|
||||
'https://api.example.test/cli/action-overrides?projectName=My+Project'
|
||||
)
|
||||
expect((init.headers as Record<string, string>)['X-ScreenCI-Secret']).toBe(
|
||||
'sk-test'
|
||||
)
|
||||
})
|
||||
|
||||
it('treats a 404 (endpoint not deployed) as no overrides', async () => {
|
||||
const client = createActionOverridesClient(fakeFetch(404))
|
||||
await expect(client.fetchActionOverrides(INPUT)).resolves.toEqual({})
|
||||
})
|
||||
|
||||
it('throws on other failures so callers can degrade loudly or quietly', async () => {
|
||||
const client = createActionOverridesClient(fakeFetch(500))
|
||||
await expect(client.fetchActionOverrides(INPUT)).rejects.toThrow('500')
|
||||
})
|
||||
|
||||
it('tolerates malformed bodies', async () => {
|
||||
for (const body of [{}, { overrides: null }, { overrides: [1] }]) {
|
||||
const client = createActionOverridesClient(fakeFetch(200, body))
|
||||
await expect(client.fetchActionOverrides(INPUT)).resolves.toEqual({})
|
||||
}
|
||||
const client = createActionOverridesClient(
|
||||
fakeFetch(200, { overrides: { good: { k: 1 }, bad: 'x' } })
|
||||
)
|
||||
await expect(client.fetchActionOverrides(INPUT)).resolves.toEqual({
|
||||
good: { k: 1 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Backend client for web-editor action-parameter overrides, behind an injected
|
||||
* interface so the CLI can be tested with fakes and callers can degrade
|
||||
* gracefully when the backend does not (yet) serve the endpoint.
|
||||
*
|
||||
* The used parameter values (with explicit/default provenance and the actual
|
||||
* `used` values) reach the backend inside the uploaded `data.json`
|
||||
* (`RecordingData.actionParams`); this client only reads the editor's current
|
||||
* override state back.
|
||||
*/
|
||||
import type { ActionOverridesByVideo } from './actionParams.js'
|
||||
|
||||
export type ActionOverridesClient = {
|
||||
/**
|
||||
* Fetch the project's current editor overrides, keyed by video name then
|
||||
* `"<selector>|<method>|<occurrence>|<optionPath>"`. Returns an empty map
|
||||
* when the project has none or the endpoint is unavailable (404); throws on
|
||||
* other failures so callers can decide how loudly to degrade.
|
||||
*/
|
||||
fetchActionOverrides(input: {
|
||||
apiUrl: string
|
||||
secret: string
|
||||
projectName: string
|
||||
}): Promise<ActionOverridesByVideo>
|
||||
}
|
||||
|
||||
/**
|
||||
* The real client: `GET /cli/action-overrides?projectName=...` authenticated
|
||||
* with the project secret, response body `{ overrides }`. `fetchImpl` is
|
||||
* injected for tests.
|
||||
*/
|
||||
export function createActionOverridesClient(
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): ActionOverridesClient {
|
||||
return {
|
||||
async fetchActionOverrides({ apiUrl, secret, projectName }) {
|
||||
const params = new URLSearchParams({ projectName })
|
||||
const res = await fetchImpl(
|
||||
`${apiUrl}/cli/action-overrides?${params.toString()}`,
|
||||
{ headers: { 'X-ScreenCI-Secret': secret } }
|
||||
)
|
||||
// A backend without the endpoint (not deployed yet) means no overrides.
|
||||
if (res.status === 404) return {}
|
||||
if (!res.ok) {
|
||||
throw new Error(`action-overrides fetch failed (${res.status})`)
|
||||
}
|
||||
const body = (await res.json()) as { overrides?: unknown }
|
||||
const overrides = body.overrides
|
||||
if (
|
||||
overrides === undefined ||
|
||||
overrides === null ||
|
||||
typeof overrides !== 'object' ||
|
||||
Array.isArray(overrides)
|
||||
) {
|
||||
return {}
|
||||
}
|
||||
const result: ActionOverridesByVideo = {}
|
||||
for (const [videoName, perVideo] of Object.entries(overrides)) {
|
||||
if (
|
||||
typeof perVideo !== 'object' ||
|
||||
perVideo === null ||
|
||||
Array.isArray(perVideo)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
result[videoName] = { ...(perVideo as Record<string, unknown>) }
|
||||
}
|
||||
return result
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Default client used by the CLI. */
|
||||
export const defaultActionOverridesClient: ActionOverridesClient =
|
||||
createActionOverridesClient()
|
||||
|
||||
/** No-overrides client for tests and offline paths. */
|
||||
export const stubActionOverridesClient: ActionOverridesClient = {
|
||||
fetchActionOverrides: async () => ({}),
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ACTION_PARAM_DEFAULTS,
|
||||
ActionParamCollector,
|
||||
@@ -116,92 +116,6 @@ describe('ActionParamCollector', () => {
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
it('applies a matching override, logs it, and keeps code provenance', () => {
|
||||
const log = vi.fn()
|
||||
const collector = new ActionParamCollector(
|
||||
{ [actionParamKey(SELECTOR, 'click', 0, 'move.duration')]: 250 },
|
||||
log
|
||||
)
|
||||
const effective = collector.apply(SELECTOR, 'click', {
|
||||
'move.duration': { explicit: 400, fallback: 900 },
|
||||
})
|
||||
expect(effective).toEqual({ 'move.duration': 250 })
|
||||
// The record keeps the code value plus the actually used value.
|
||||
expect(collector.getRecords()[0]!.params['move.duration']).toEqual({
|
||||
value: 400,
|
||||
source: 'explicit',
|
||||
used: 250,
|
||||
})
|
||||
expect(log).toHaveBeenCalledTimes(1)
|
||||
expect(log.mock.calls[0]![0]).toContain('editor override')
|
||||
expect(log.mock.calls[0]![0]).toContain(SELECTOR)
|
||||
expect(log.mock.calls[0]![0]).toContain('move.duration')
|
||||
expect(log.mock.calls[0]![0]).toContain('250')
|
||||
expect(log.mock.calls[0]![0]).toContain('400')
|
||||
})
|
||||
|
||||
it('only overrides the matching occurrence', () => {
|
||||
const log = vi.fn()
|
||||
const collector = new ActionParamCollector(
|
||||
{ [actionParamKey(SELECTOR, 'click', 1, 'move.duration')]: 250 },
|
||||
log
|
||||
)
|
||||
const first = collector.apply(SELECTOR, 'click', {
|
||||
'move.duration': { explicit: undefined, fallback: 900 },
|
||||
})
|
||||
const second = collector.apply(SELECTOR, 'click', {
|
||||
'move.duration': { explicit: undefined, fallback: 900 },
|
||||
})
|
||||
expect(first).toEqual({ 'move.duration': 900 })
|
||||
expect(second).toEqual({ 'move.duration': 250 })
|
||||
expect(log).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores non-matching override keys and inapplicable values', () => {
|
||||
const log = vi.fn()
|
||||
const collector = new ActionParamCollector(
|
||||
{
|
||||
'other|click|0|move.duration': 250,
|
||||
[actionParamKey(SELECTOR, 'click', 0, 'noWaitAfter')]: null,
|
||||
},
|
||||
log
|
||||
)
|
||||
const effective = collector.apply(SELECTOR, 'click', {
|
||||
'move.duration': { explicit: undefined, fallback: 900 },
|
||||
noWaitAfter: { explicit: undefined, fallback: true },
|
||||
})
|
||||
expect(effective).toEqual({ 'move.duration': 900, noWaitAfter: true })
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats an override equal to the code value as a no-op (no log, no used)', () => {
|
||||
const log = vi.fn()
|
||||
const collector = new ActionParamCollector(
|
||||
{ [actionParamKey(SELECTOR, 'click', 0, 'move.duration')]: 400 },
|
||||
log
|
||||
)
|
||||
const effective = collector.apply(SELECTOR, 'click', {
|
||||
'move.duration': { explicit: 400, fallback: 900 },
|
||||
})
|
||||
expect(effective).toEqual({ 'move.duration': 400 })
|
||||
expect(collector.getRecords()[0]!.params['move.duration']).toEqual({
|
||||
value: 400,
|
||||
source: 'explicit',
|
||||
})
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts structured overrides (e.g. position)', () => {
|
||||
const collector = new ActionParamCollector(
|
||||
{ [actionParamKey(SELECTOR, 'click', 0, 'position')]: { x: 1, y: 2 } },
|
||||
vi.fn()
|
||||
)
|
||||
const effective = collector.apply(SELECTOR, 'click', {
|
||||
position: { explicit: undefined, fallback: undefined },
|
||||
})
|
||||
expect(effective).toEqual({ position: { x: 1, y: 2 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('ACTION_PARAM_DEFAULTS', () => {
|
||||
|
||||
+11
-82
@@ -2,17 +2,10 @@
|
||||
* Action-parameter provenance. Every instrumented Playwright action (click,
|
||||
* fill, hover, ...) reports which option values it used and whether each value
|
||||
* was explicit at the call site or a default. The records are serialized into
|
||||
* `data.json` (so the backend learns the used values and their provenance) and
|
||||
* snapshotted under `.screenci` so the next `record` run can warn when a web
|
||||
* editor override shadows an explicitly code-set value.
|
||||
*
|
||||
* Editor overrides arrive keyed by
|
||||
* `"<selector>|<method>|<occurrence>|<optionPath>"` and are applied to the
|
||||
* action before it runs. An override that actually changes the used value is
|
||||
* logged and recorded as `used`, so the uploaded recording tells the editor
|
||||
* which values the recording really ran with.
|
||||
* `data.json` so the backend learns the used values and their provenance; the
|
||||
* web editor's edits themselves are codegen'd straight into the sources by
|
||||
* `screenci dev`, so the recorded code values are always what a run used.
|
||||
*/
|
||||
import { logger } from './logger.js'
|
||||
import {
|
||||
DEFAULT_CLICK_MOUSE_MOVE_DURATION,
|
||||
DEFAULT_CURSOR_CURVE,
|
||||
@@ -40,17 +33,11 @@ export type ActionMethod =
|
||||
/** Whether a parameter value was set at the call site or fell back to a default. */
|
||||
export type ParamSource = 'explicit' | 'default'
|
||||
|
||||
/** One recorded parameter: the code value, its provenance, and the used value. */
|
||||
/** One recorded parameter: the code value and its provenance. */
|
||||
export type ActionParamValue = {
|
||||
/** The code value (JSON-safe; `null` when the code value is undefined). */
|
||||
value: unknown
|
||||
source: ParamSource
|
||||
/**
|
||||
* The value the recording actually ran with, present only when an editor
|
||||
* override changed it (differs from `value`). The editor reads this to update
|
||||
* its own copy of the options after a recording.
|
||||
*/
|
||||
used?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,12 +65,6 @@ export type ActionParamSpec = Record<
|
||||
{ explicit: unknown; fallback: unknown }
|
||||
>
|
||||
|
||||
/** Editor overrides for one video: `paramKey -> value`. */
|
||||
export type ActionOverrides = Record<string, unknown>
|
||||
|
||||
/** Editor overrides keyed by video name. */
|
||||
export type ActionOverridesByVideo = Record<string, ActionOverrides>
|
||||
|
||||
/** The cursor-move option defaults shared by every mouse-driven action. */
|
||||
function cursorMoveDefaults(delayAfter: number): Record<string, unknown> {
|
||||
return {
|
||||
@@ -194,45 +175,17 @@ export function jsonEqual(a: unknown, b: unknown): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an override value is acceptable for a parameter: primitives always,
|
||||
* plain objects/arrays for structured options (e.g. `position`). Functions and
|
||||
* symbols never (they cannot come from JSON anyway).
|
||||
*/
|
||||
function isApplicableOverride(value: unknown): boolean {
|
||||
const t = typeof value
|
||||
return (
|
||||
t === 'number' ||
|
||||
t === 'string' ||
|
||||
t === 'boolean' ||
|
||||
(t === 'object' && value !== null)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the {@link ActionParamRecord}s of one recording and applies editor
|
||||
* overrides. One instance per `EventRecorder`; constructor-injected so tests
|
||||
* build it directly with fake overrides and a spy logger.
|
||||
* Collects the {@link ActionParamRecord}s of one recording. One instance per
|
||||
* `EventRecorder`.
|
||||
*/
|
||||
export class ActionParamCollector {
|
||||
private readonly records: ActionParamRecord[] = []
|
||||
private readonly occurrences = new Map<string, number>()
|
||||
private readonly overrides: ActionOverrides
|
||||
private readonly log: (message: string) => void
|
||||
|
||||
constructor(
|
||||
overrides: ActionOverrides = {},
|
||||
log: (message: string) => void = (message) => logger.info(message)
|
||||
) {
|
||||
this.overrides = overrides
|
||||
this.log = log
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one action call and return the effective option values: the editor
|
||||
* override when present, else the explicit call-site value, else the
|
||||
* default. Only an override that actually changes the used value is logged
|
||||
* and stored as `used`; an override equal to the code value is a no-op. The
|
||||
* `value`/`source` fields always describe the code side.
|
||||
* Record one action call and return the effective option values: the
|
||||
* explicit call-site value, else the default. The `value`/`source` fields
|
||||
* describe the code side.
|
||||
*/
|
||||
apply(
|
||||
selector: string,
|
||||
@@ -250,32 +203,8 @@ export class ActionParamCollector {
|
||||
const codeValue = explicit !== undefined ? explicit : fallback
|
||||
const source: ParamSource =
|
||||
explicit !== undefined ? 'explicit' : 'default'
|
||||
const recordedCodeValue = toRecordedValue(codeValue)
|
||||
params[optionPath] = { value: recordedCodeValue, source }
|
||||
|
||||
const overrideKey = actionParamKey(
|
||||
selector,
|
||||
method,
|
||||
occurrence,
|
||||
optionPath
|
||||
)
|
||||
const override = this.overrides[overrideKey]
|
||||
if (
|
||||
override !== undefined &&
|
||||
isApplicableOverride(override) &&
|
||||
!jsonEqual(override, recordedCodeValue)
|
||||
) {
|
||||
effective[optionPath] = override
|
||||
params[optionPath]!.used = override
|
||||
this.log(
|
||||
`[screenci] editor override: ${selector} ${method} ${optionPath}: ` +
|
||||
`${JSON.stringify(override)} (code: ${JSON.stringify(
|
||||
recordedCodeValue
|
||||
)}, ${source})`
|
||||
)
|
||||
} else {
|
||||
effective[optionPath] = codeValue
|
||||
}
|
||||
params[optionPath] = { value: toRecordedValue(codeValue), source }
|
||||
effective[optionPath] = codeValue
|
||||
}
|
||||
|
||||
this.records.push({
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ACTION_PARAMS_SNAPSHOT_FILE,
|
||||
collectActionParamsFromRecordings,
|
||||
diffOverridesAgainstSnapshot,
|
||||
mergeActionParamsSnapshot,
|
||||
readActionParamsSnapshot,
|
||||
updateActionParamsSnapshot,
|
||||
writeActionParamsSnapshot,
|
||||
type ActionParamsSnapshot,
|
||||
} from './actionParamsSnapshot.js'
|
||||
import type { ActionParamRecord } from './actionParams.js'
|
||||
|
||||
const CLICK_RECORD: ActionParamRecord = {
|
||||
selector: "getByRole('button', { name: 'Save' })",
|
||||
method: 'click',
|
||||
occurrence: 0,
|
||||
params: {
|
||||
'move.duration': { value: 400, source: 'explicit' },
|
||||
'move.easing': { value: 'ease-in-out', source: 'default' },
|
||||
},
|
||||
}
|
||||
|
||||
describe('actionParamsSnapshot', () => {
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'screenci-action-params-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('round-trips a snapshot through write and read', () => {
|
||||
const snapshot: ActionParamsSnapshot = {
|
||||
version: 1,
|
||||
videos: { 'My video': [CLICK_RECORD] },
|
||||
}
|
||||
writeActionParamsSnapshot(dir, snapshot)
|
||||
expect(readActionParamsSnapshot(dir)).toEqual(snapshot)
|
||||
})
|
||||
|
||||
it('returns an empty snapshot for a missing or corrupt file', () => {
|
||||
expect(readActionParamsSnapshot(dir)).toEqual({ version: 1, videos: {} })
|
||||
writeFileSync(join(dir, ACTION_PARAMS_SNAPSHOT_FILE), 'not json')
|
||||
expect(readActionParamsSnapshot(dir)).toEqual({ version: 1, videos: {} })
|
||||
})
|
||||
|
||||
it('collects actionParams from per-recording data.json files', () => {
|
||||
mkdirSync(join(dir, 'My-video'))
|
||||
writeFileSync(
|
||||
join(dir, 'My-video', 'data.json'),
|
||||
JSON.stringify({
|
||||
metadata: { videoName: 'My video' },
|
||||
actionParams: [CLICK_RECORD],
|
||||
})
|
||||
)
|
||||
mkdirSync(join(dir, 'broken'))
|
||||
writeFileSync(join(dir, 'broken', 'data.json'), 'not json')
|
||||
writeFileSync(join(dir, 'stray.json'), '{}')
|
||||
|
||||
expect(collectActionParamsFromRecordings(dir)).toEqual({
|
||||
'My video': [CLICK_RECORD],
|
||||
})
|
||||
})
|
||||
|
||||
it('merge preserves videos not recorded this run', () => {
|
||||
const existing: ActionParamsSnapshot = {
|
||||
version: 1,
|
||||
videos: { Old: [CLICK_RECORD] },
|
||||
}
|
||||
expect(
|
||||
mergeActionParamsSnapshot(existing, { New: [CLICK_RECORD] })
|
||||
).toEqual({
|
||||
version: 1,
|
||||
videos: { Old: [CLICK_RECORD], New: [CLICK_RECORD] },
|
||||
})
|
||||
})
|
||||
|
||||
it('updateActionParamsSnapshot aggregates recordings into the snapshot file', () => {
|
||||
mkdirSync(join(dir, 'My-video'))
|
||||
writeFileSync(
|
||||
join(dir, 'My-video', 'data.json'),
|
||||
JSON.stringify({
|
||||
metadata: { videoName: 'My video' },
|
||||
actionParams: [CLICK_RECORD],
|
||||
})
|
||||
)
|
||||
updateActionParamsSnapshot(dir)
|
||||
expect(existsSync(join(dir, ACTION_PARAMS_SNAPSHOT_FILE))).toBe(true)
|
||||
expect(readActionParamsSnapshot(dir).videos['My video']).toEqual([
|
||||
CLICK_RECORD,
|
||||
])
|
||||
})
|
||||
|
||||
it('updateActionParamsSnapshot writes nothing when no recordings exist', () => {
|
||||
updateActionParamsSnapshot(dir)
|
||||
expect(existsSync(join(dir, ACTION_PARAMS_SNAPSHOT_FILE))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('diffOverridesAgainstSnapshot', () => {
|
||||
const snapshot: ActionParamsSnapshot = {
|
||||
version: 1,
|
||||
videos: { 'My video': [CLICK_RECORD] },
|
||||
}
|
||||
const selector = CLICK_RECORD.selector
|
||||
|
||||
it('reports overrides shadowing explicit code values', () => {
|
||||
const collisions = diffOverridesAgainstSnapshot(snapshot, {
|
||||
'My video': { [`${selector}|click|0|move.duration`]: 250 },
|
||||
})
|
||||
expect(collisions).toEqual([
|
||||
{
|
||||
videoName: 'My video',
|
||||
selector,
|
||||
method: 'click',
|
||||
occurrence: 0,
|
||||
optionPath: 'move.duration',
|
||||
codeValue: 400,
|
||||
editorValue: 250,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores overrides of defaulted params, unknown keys, and unknown videos', () => {
|
||||
expect(
|
||||
diffOverridesAgainstSnapshot(snapshot, {
|
||||
'My video': {
|
||||
[`${selector}|click|0|move.easing`]: 'linear',
|
||||
[`${selector}|click|1|move.duration`]: 250,
|
||||
[`${selector}|hover|0|move.duration`]: 250,
|
||||
malformed: 1,
|
||||
},
|
||||
'Other video': { [`${selector}|click|0|move.duration`]: 250 },
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('parses selectors that themselves contain pipes', () => {
|
||||
const pipedSelector = "getByText('a|b')"
|
||||
const piped: ActionParamsSnapshot = {
|
||||
version: 1,
|
||||
videos: {
|
||||
V: [
|
||||
{
|
||||
selector: pipedSelector,
|
||||
method: 'click',
|
||||
occurrence: 0,
|
||||
params: { timeout: { value: 5, source: 'explicit' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
expect(
|
||||
diffOverridesAgainstSnapshot(piped, {
|
||||
V: { [`${pipedSelector}|click|0|timeout`]: 9 },
|
||||
})
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,187 +0,0 @@
|
||||
/**
|
||||
* The `.screenci/action-params.json` snapshot: the latest known action-parameter
|
||||
* provenance per video, aggregated by the CLI from the per-recording `data.json`
|
||||
* files after every record run. It is never wiped (the record command's
|
||||
* directory clear preserves it) so the next run can compare the web editor's
|
||||
* overrides against the previous run's explicit code values and warn when an
|
||||
* override shadows a value the user set explicitly in code.
|
||||
*/
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'fs'
|
||||
import { join } from 'path'
|
||||
import type { ActionOverrides, ActionParamRecord } from './actionParams.js'
|
||||
|
||||
/** File name of the snapshot inside `.screenci`. Preserved across runs. */
|
||||
export const ACTION_PARAMS_SNAPSHOT_FILE = 'action-params.json'
|
||||
|
||||
export type ActionParamsSnapshot = {
|
||||
version: 1
|
||||
videos: Record<string, ActionParamRecord[]>
|
||||
}
|
||||
|
||||
/** An editor override that shadows an explicitly code-set parameter value. */
|
||||
export type OverrideCollision = {
|
||||
videoName: string
|
||||
selector: string
|
||||
method: string
|
||||
occurrence: number
|
||||
optionPath: string
|
||||
codeValue: unknown
|
||||
editorValue: unknown
|
||||
}
|
||||
|
||||
const EMPTY_SNAPSHOT: ActionParamsSnapshot = { version: 1, videos: {} }
|
||||
|
||||
/** Read the snapshot; tolerant of a missing or corrupt file (empty snapshot). */
|
||||
export function readActionParamsSnapshot(
|
||||
screenciDir: string
|
||||
): ActionParamsSnapshot {
|
||||
const filePath = join(screenciDir, ACTION_PARAMS_SNAPSHOT_FILE)
|
||||
if (!existsSync(filePath)) return { ...EMPTY_SNAPSHOT, videos: {} }
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(filePath, 'utf8'))
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
typeof (parsed as { videos?: unknown }).videos !== 'object' ||
|
||||
(parsed as { videos?: unknown }).videos === null
|
||||
) {
|
||||
return { ...EMPTY_SNAPSHOT, videos: {} }
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
videos: (parsed as ActionParamsSnapshot).videos,
|
||||
}
|
||||
} catch {
|
||||
return { ...EMPTY_SNAPSHOT, videos: {} }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect `actionParams` (keyed by `metadata.videoName`) from every
|
||||
* `.screenci/<recording>/data.json` written by the run that just finished.
|
||||
* Recordings without action params contribute an empty list (the video ran,
|
||||
* and its latest truth is "no tracked actions").
|
||||
*/
|
||||
export function collectActionParamsFromRecordings(
|
||||
screenciDir: string
|
||||
): Record<string, ActionParamRecord[]> {
|
||||
const collected: Record<string, ActionParamRecord[]> = {}
|
||||
if (!existsSync(screenciDir)) return collected
|
||||
for (const entry of readdirSync(screenciDir)) {
|
||||
const dataPath = join(screenciDir, entry, 'data.json')
|
||||
try {
|
||||
if (!statSync(join(screenciDir, entry)).isDirectory()) continue
|
||||
if (!existsSync(dataPath)) continue
|
||||
const parsed: unknown = JSON.parse(readFileSync(dataPath, 'utf8'))
|
||||
if (typeof parsed !== 'object' || parsed === null) continue
|
||||
const data = parsed as {
|
||||
metadata?: { videoName?: unknown }
|
||||
actionParams?: unknown
|
||||
}
|
||||
const videoName = data.metadata?.videoName
|
||||
if (typeof videoName !== 'string') continue
|
||||
const actionParams = Array.isArray(data.actionParams)
|
||||
? (data.actionParams as ActionParamRecord[])
|
||||
: []
|
||||
// Per-language passes share one videoName; keep the first pass's params
|
||||
// (each language performs the same actions).
|
||||
if (!(videoName in collected) || actionParams.length > 0) {
|
||||
collected[videoName] = actionParams
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return collected
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge freshly recorded params over the existing snapshot. Videos not recorded
|
||||
* this run keep their previous entries (a filtered run must not lose them).
|
||||
*/
|
||||
export function mergeActionParamsSnapshot(
|
||||
existing: ActionParamsSnapshot,
|
||||
recorded: Record<string, ActionParamRecord[]>
|
||||
): ActionParamsSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
videos: { ...existing.videos, ...recorded },
|
||||
}
|
||||
}
|
||||
|
||||
/** Write the snapshot (write-then-rename so a crash never corrupts it). */
|
||||
export function writeActionParamsSnapshot(
|
||||
screenciDir: string,
|
||||
snapshot: ActionParamsSnapshot
|
||||
): void {
|
||||
const filePath = join(screenciDir, ACTION_PARAMS_SNAPSHOT_FILE)
|
||||
const tmpPath = `${filePath}.tmp`
|
||||
writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2))
|
||||
renameSync(tmpPath, filePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the snapshot from the recordings a run just produced: read, merge,
|
||||
* write. Called by the CLI after Playwright exits; best-effort by design (the
|
||||
* caller catches errors).
|
||||
*/
|
||||
export function updateActionParamsSnapshot(screenciDir: string): void {
|
||||
const recorded = collectActionParamsFromRecordings(screenciDir)
|
||||
if (Object.keys(recorded).length === 0) return
|
||||
writeActionParamsSnapshot(
|
||||
screenciDir,
|
||||
mergeActionParamsSnapshot(readActionParamsSnapshot(screenciDir), recorded)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find editor overrides that shadow explicitly code-set values: an override
|
||||
* whose key resolves to a snapshot parameter with `source: 'explicit'`. Pure;
|
||||
* a missing snapshot (first run) or unknown keys produce no collisions.
|
||||
*/
|
||||
export function diffOverridesAgainstSnapshot(
|
||||
snapshot: ActionParamsSnapshot,
|
||||
overridesByVideo: Record<string, ActionOverrides>
|
||||
): OverrideCollision[] {
|
||||
const collisions: OverrideCollision[] = []
|
||||
for (const [videoName, overrides] of Object.entries(overridesByVideo)) {
|
||||
const records = snapshot.videos[videoName]
|
||||
if (records === undefined) continue
|
||||
for (const [key, editorValue] of Object.entries(overrides)) {
|
||||
// Key shape: "<selector>|<method>|<occurrence>|<optionPath>". The selector
|
||||
// itself may contain pipes only in rare text matchers; parse from the end.
|
||||
const parts = key.split('|')
|
||||
if (parts.length < 4) continue
|
||||
const optionPath = parts[parts.length - 1]!
|
||||
const occurrence = Number(parts[parts.length - 2])
|
||||
const method = parts[parts.length - 3]!
|
||||
const selector = parts.slice(0, parts.length - 3).join('|')
|
||||
if (!Number.isInteger(occurrence)) continue
|
||||
const record = records.find(
|
||||
(r) =>
|
||||
r.selector === selector &&
|
||||
r.method === method &&
|
||||
r.occurrence === occurrence
|
||||
)
|
||||
const param = record?.params[optionPath]
|
||||
if (param === undefined || param.source !== 'explicit') continue
|
||||
collisions.push({
|
||||
videoName,
|
||||
selector,
|
||||
method,
|
||||
occurrence,
|
||||
optionPath,
|
||||
codeValue: param.value,
|
||||
editorValue,
|
||||
})
|
||||
}
|
||||
}
|
||||
return collisions
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { compareWebStateToSnapshot, formatStatusReport } from './actionSync.js'
|
||||
import type { ActionParamsSnapshot } from './actionParamsSnapshot.js'
|
||||
|
||||
const SELECTOR = "getByRole('button', { name: 'Save' })"
|
||||
|
||||
const SNAPSHOT: ActionParamsSnapshot = {
|
||||
version: 1,
|
||||
videos: {
|
||||
'My video': [
|
||||
{
|
||||
selector: SELECTOR,
|
||||
method: 'click',
|
||||
occurrence: 0,
|
||||
params: {
|
||||
'move.duration': { value: 400, source: 'explicit' },
|
||||
'move.easing': { value: 'ease-in-out', source: 'default' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
function key(optionPath: string, occurrence = 0): string {
|
||||
return `${SELECTOR}|click|${occurrence}|${optionPath}`
|
||||
}
|
||||
|
||||
describe('compareWebStateToSnapshot', () => {
|
||||
it('classifies change, remove, codify, in-sync, and stale', () => {
|
||||
const comparison = compareWebStateToSnapshot(SNAPSHOT, {
|
||||
'My video': {
|
||||
[key('move.duration')]: 250, // explicit 400 -> change
|
||||
[key('move.easing')]: 'linear', // default -> codify
|
||||
[key('move.duration', 1)]: 111, // unknown occurrence -> stale
|
||||
},
|
||||
'Other video': { [key('move.duration')]: 5 }, // video unknown -> stale
|
||||
})
|
||||
const my = comparison.videos.find((v) => v.videoName === 'My video')!
|
||||
expect(my.inSnapshot).toBe(true)
|
||||
expect(
|
||||
my.overrides.map((o) => [o.optionPath, o.occurrence, o.kind])
|
||||
).toEqual([
|
||||
['move.duration', 0, 'change'],
|
||||
['move.easing', 0, 'codify'],
|
||||
['move.duration', 1, 'stale'],
|
||||
])
|
||||
const other = comparison.videos.find((v) => v.videoName === 'Other video')!
|
||||
expect(other.inSnapshot).toBe(false)
|
||||
expect(other.overrides[0]!.kind).toBe('stale')
|
||||
})
|
||||
|
||||
it('classifies an override equal to the method default on an explicit value as remove', () => {
|
||||
const comparison = compareWebStateToSnapshot(SNAPSHOT, {
|
||||
'My video': { [key('move.duration')]: 900 },
|
||||
})
|
||||
const override = comparison.videos[0]!.overrides[0]!
|
||||
expect(override.kind).toBe('remove')
|
||||
expect(override.codeValue).toBe(400)
|
||||
expect(override.defaultValue).toBe(900)
|
||||
})
|
||||
|
||||
it('classifies an override equal to the code value as in-sync', () => {
|
||||
const comparison = compareWebStateToSnapshot(SNAPSHOT, {
|
||||
'My video': { [key('move.duration')]: 400 },
|
||||
})
|
||||
expect(comparison.videos[0]!.overrides[0]!.kind).toBe('in-sync')
|
||||
})
|
||||
|
||||
it('filters videos with a grep regex', () => {
|
||||
const comparison = compareWebStateToSnapshot(
|
||||
SNAPSHOT,
|
||||
{
|
||||
'My video': { [key('move.duration')]: 250 },
|
||||
'Other video': { [key('move.duration')]: 250 },
|
||||
},
|
||||
/^My/
|
||||
)
|
||||
expect(comparison.videos.map((v) => v.videoName)).toEqual(['My video'])
|
||||
})
|
||||
|
||||
it('flags an empty snapshot', () => {
|
||||
const comparison = compareWebStateToSnapshot({ version: 1, videos: {} }, {})
|
||||
expect(comparison.snapshotEmpty).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatStatusReport', () => {
|
||||
it('reports in-sync when there are no overrides', () => {
|
||||
const lines = formatStatusReport(compareWebStateToSnapshot(SNAPSHOT, {}))
|
||||
expect(lines).toEqual([
|
||||
'Editor overrides: none. Code and web editor are in sync.',
|
||||
])
|
||||
})
|
||||
|
||||
it('describes each override kind', () => {
|
||||
const lines = formatStatusReport(
|
||||
compareWebStateToSnapshot(SNAPSHOT, {
|
||||
'My video': {
|
||||
[key('move.duration')]: 250,
|
||||
[key('move.easing')]: 'linear',
|
||||
[key('move.duration', 1)]: 111,
|
||||
},
|
||||
})
|
||||
).join('\n')
|
||||
expect(lines).toContain('Video: My video')
|
||||
expect(lines).toContain('override shadows explicit code value')
|
||||
expect(lines).toContain('override changes a defaulted value')
|
||||
expect(lines).toContain('stale override')
|
||||
})
|
||||
})
|
||||
@@ -1,210 +0,0 @@
|
||||
/**
|
||||
* Comparison of the web editor's action-parameter overrides against the latest
|
||||
* local recording snapshot (`.screenci/action-params.json`), powering
|
||||
* `screenci status` (human report) and `screenci sync` (which brings code back
|
||||
* in sync with the editor). Pure; the CLI wires in the fetched overrides and
|
||||
* the snapshot.
|
||||
*/
|
||||
import {
|
||||
ACTION_PARAM_DEFAULTS,
|
||||
jsonEqual,
|
||||
type ActionMethod,
|
||||
type ActionOverrides,
|
||||
type ActionOverridesByVideo,
|
||||
} from './actionParams.js'
|
||||
import type { ActionParamsSnapshot } from './actionParamsSnapshot.js'
|
||||
|
||||
/**
|
||||
* How one editor override relates to the code as of the latest recording:
|
||||
*
|
||||
* - `change`: overrides an explicitly code-set value; code should change it.
|
||||
* - `remove`: resets an explicitly code-set value back to the default; code
|
||||
* should drop the explicit option.
|
||||
* - `codify`: overrides a defaulted value; code may set it explicitly.
|
||||
* - `in-sync`: the override equals the code value (nothing to do).
|
||||
* - `stale`: the override's action does not exist in the latest snapshot
|
||||
* (selector/method/occurrence mismatch); the code changed since the edit.
|
||||
*/
|
||||
export type OverrideKind = 'change' | 'remove' | 'codify' | 'in-sync' | 'stale'
|
||||
|
||||
export type OverrideAssessment = {
|
||||
kind: OverrideKind
|
||||
selector: string
|
||||
method: string
|
||||
occurrence: number
|
||||
optionPath: string
|
||||
editorValue: unknown
|
||||
/** The code value from the snapshot (absent for `stale`). */
|
||||
codeValue?: unknown
|
||||
/** The SDK default for this method+option, when known. */
|
||||
defaultValue?: unknown
|
||||
}
|
||||
|
||||
export type VideoComparison = {
|
||||
videoName: string
|
||||
/** False when the latest snapshot has no recording of this video. */
|
||||
inSnapshot: boolean
|
||||
overrides: OverrideAssessment[]
|
||||
}
|
||||
|
||||
export type WebStateComparison = {
|
||||
videos: VideoComparison[]
|
||||
/** True when no snapshot exists yet (no recorded run to compare against). */
|
||||
snapshotEmpty: boolean
|
||||
}
|
||||
|
||||
/** Parse `"<selector>|<method>|<occurrence>|<optionPath>"` (selector may contain pipes). */
|
||||
function parseParamKey(key: string): {
|
||||
selector: string
|
||||
method: string
|
||||
occurrence: number
|
||||
optionPath: string
|
||||
} | null {
|
||||
const parts = key.split('|')
|
||||
if (parts.length < 4) return null
|
||||
const optionPath = parts[parts.length - 1]!
|
||||
const occurrence = Number(parts[parts.length - 2])
|
||||
const method = parts[parts.length - 3]!
|
||||
const selector = parts.slice(0, parts.length - 3).join('|')
|
||||
if (!Number.isInteger(occurrence)) return null
|
||||
return { selector, method, occurrence, optionPath }
|
||||
}
|
||||
|
||||
function assessOverrides(
|
||||
records: ActionParamsSnapshot['videos'][string] | undefined,
|
||||
overrides: ActionOverrides
|
||||
): OverrideAssessment[] {
|
||||
const assessments: OverrideAssessment[] = []
|
||||
for (const [key, editorValue] of Object.entries(overrides)) {
|
||||
const parsed = parseParamKey(key)
|
||||
if (parsed === null) continue
|
||||
const base = { ...parsed, editorValue }
|
||||
const defaultValue =
|
||||
ACTION_PARAM_DEFAULTS[parsed.method as ActionMethod]?.[parsed.optionPath]
|
||||
const withDefault =
|
||||
defaultValue !== undefined ? { ...base, defaultValue } : base
|
||||
|
||||
const record = records?.find(
|
||||
(r) =>
|
||||
r.selector === parsed.selector &&
|
||||
r.method === parsed.method &&
|
||||
r.occurrence === parsed.occurrence
|
||||
)
|
||||
const param = record?.params[parsed.optionPath]
|
||||
if (param === undefined) {
|
||||
assessments.push({ kind: 'stale', ...withDefault })
|
||||
continue
|
||||
}
|
||||
|
||||
const codeValue = param.value
|
||||
if (jsonEqual(editorValue, codeValue)) {
|
||||
assessments.push({ kind: 'in-sync', ...withDefault, codeValue })
|
||||
} else if (param.source === 'default') {
|
||||
assessments.push({ kind: 'codify', ...withDefault, codeValue })
|
||||
} else if (
|
||||
defaultValue !== undefined &&
|
||||
jsonEqual(editorValue, defaultValue)
|
||||
) {
|
||||
assessments.push({ kind: 'remove', ...withDefault, codeValue })
|
||||
} else {
|
||||
assessments.push({ kind: 'change', ...withDefault, codeValue })
|
||||
}
|
||||
}
|
||||
return assessments
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare every video's editor overrides against the latest snapshot.
|
||||
* `grep` filters video names (same semantics as Playwright's `--grep`: a
|
||||
* regular expression tested against the name).
|
||||
*/
|
||||
export function compareWebStateToSnapshot(
|
||||
snapshot: ActionParamsSnapshot,
|
||||
overridesByVideo: ActionOverridesByVideo,
|
||||
grep?: RegExp
|
||||
): WebStateComparison {
|
||||
const videos: VideoComparison[] = []
|
||||
for (const [videoName, overrides] of Object.entries(overridesByVideo)) {
|
||||
if (grep !== undefined && !grep.test(videoName)) continue
|
||||
const records = snapshot.videos[videoName]
|
||||
videos.push({
|
||||
videoName,
|
||||
inSnapshot: records !== undefined,
|
||||
overrides: assessOverrides(records, overrides),
|
||||
})
|
||||
}
|
||||
return {
|
||||
videos,
|
||||
snapshotEmpty: Object.keys(snapshot.videos).length === 0,
|
||||
}
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
/** Human-readable `screenci status` report lines. */
|
||||
export function formatStatusReport(comparison: WebStateComparison): string[] {
|
||||
const lines: string[] = []
|
||||
if (comparison.snapshotEmpty) {
|
||||
lines.push(
|
||||
'No local recording snapshot yet: run `screenci record` once to compare.'
|
||||
)
|
||||
}
|
||||
const totalOverrides = comparison.videos.reduce(
|
||||
(sum, video) => sum + video.overrides.length,
|
||||
0
|
||||
)
|
||||
if (totalOverrides === 0) {
|
||||
lines.push('Editor overrides: none. Code and web editor are in sync.')
|
||||
return lines
|
||||
}
|
||||
for (const video of comparison.videos) {
|
||||
if (video.overrides.length === 0) continue
|
||||
lines.push(`Video: ${video.videoName}`)
|
||||
if (!video.inSnapshot) {
|
||||
lines.push(
|
||||
' (not in the latest local snapshot: record it to compare precisely)'
|
||||
)
|
||||
}
|
||||
for (const o of video.overrides) {
|
||||
const where = `${o.selector} ${o.method}#${o.occurrence} ${o.optionPath}`
|
||||
switch (o.kind) {
|
||||
case 'change':
|
||||
lines.push(
|
||||
` override shadows explicit code value: ${where}: ` +
|
||||
`code ${formatValue(o.codeValue)} -> editor ${formatValue(o.editorValue)}`
|
||||
)
|
||||
break
|
||||
case 'remove':
|
||||
lines.push(
|
||||
` override resets explicit code value to the default: ${where}: ` +
|
||||
`code ${formatValue(o.codeValue)} -> default ${formatValue(o.defaultValue)}`
|
||||
)
|
||||
break
|
||||
case 'codify':
|
||||
lines.push(
|
||||
` override changes a defaulted value: ${where}: ` +
|
||||
`default ${formatValue(o.codeValue)} -> editor ${formatValue(o.editorValue)}`
|
||||
)
|
||||
break
|
||||
case 'in-sync':
|
||||
lines.push(
|
||||
` override matches code (no effect): ${where}: ${formatValue(o.editorValue)}`
|
||||
)
|
||||
break
|
||||
case 'stale':
|
||||
lines.push(
|
||||
` stale override (action not in the latest recording): ${where}: ` +
|
||||
`editor ${formatValue(o.editorValue)}`
|
||||
)
|
||||
break
|
||||
default: {
|
||||
const _exhaustive: never = o.kind
|
||||
throw new Error(`Unknown override kind: ${String(_exhaustive)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Applies a single editor codegen request to the test sources.
|
||||
*
|
||||
* The dev channel delivers one unified timeline-edit record (paramEdit,
|
||||
* mediaEdit, zoomEdit, gapSpanEdit, gapPointEdit, overlayDeclEdit or
|
||||
* renameEdit) addressed by editId. The record is written straight into the
|
||||
* `.screenci.ts` source through the same codemod pipeline `screenci sync`
|
||||
* uses; the call site is located via the editable entries of the video's kept
|
||||
* recording data. Throws when the edit cannot be applied, so the listener
|
||||
* reports the request failed and the editor reverts the optimistic value.
|
||||
*/
|
||||
import { planCodeSync } from './codeSync.js'
|
||||
import type { TsModule } from './codemod.js'
|
||||
import type { DevCodegenRequest } from './devListen.js'
|
||||
import {
|
||||
splitTimelineEditsByVideo,
|
||||
type EditableSnapshot,
|
||||
} from './editableSnapshot.js'
|
||||
|
||||
export type ApplyCodegenDeps = {
|
||||
ts: TsModule
|
||||
readFile: (path: string) => string | null
|
||||
writeFile: (path: string, content: string) => void
|
||||
/**
|
||||
* Editable entries per video (key, editId, defaults, source file), built
|
||||
* from the kept recording data. Used to locate call sites by editId.
|
||||
*/
|
||||
editableSnapshot: EditableSnapshot
|
||||
}
|
||||
|
||||
export function applyCodegenRequest(
|
||||
request: DevCodegenRequest,
|
||||
deps: ApplyCodegenDeps
|
||||
): void {
|
||||
let record: unknown
|
||||
try {
|
||||
record = JSON.parse(request.editJson)
|
||||
} catch {
|
||||
throw new Error(`Edit "${request.editId}" carries invalid JSON`)
|
||||
}
|
||||
if (typeof record !== 'object' || record === null) {
|
||||
throw new Error(`Edit "${request.editId}" is not an edit record`)
|
||||
}
|
||||
|
||||
const split = splitTimelineEditsByVideo({
|
||||
[request.videoName]: { version: 3, edits: [record] },
|
||||
})
|
||||
|
||||
const plan = planCodeSync(
|
||||
{
|
||||
// The codegen path carries no web action-param state to diff; the
|
||||
// single record IS the change.
|
||||
comparison: { videos: [], snapshotEmpty: true },
|
||||
actionSnapshot: { version: 1, videos: {} },
|
||||
editableSnapshot: deps.editableSnapshot,
|
||||
editableOverrides: split.overrides,
|
||||
codifyEdits: split.codify,
|
||||
removedCodifyEdits: split.removedCodify,
|
||||
renames: split.renames,
|
||||
overlayDeclEdits: split.overlayDecls,
|
||||
},
|
||||
{ ts: deps.ts, readFile: deps.readFile }
|
||||
)
|
||||
|
||||
if (plan.unappliable.length > 0) {
|
||||
const reasons = plan.unappliable.map((item) => item.reason).join('; ')
|
||||
throw new Error(
|
||||
`Edit "${request.editId}" could not be applied to code: ${reasons}`
|
||||
)
|
||||
}
|
||||
|
||||
for (const file of plan.files) {
|
||||
if (file.after !== file.before) deps.writeFile(file.path, file.after)
|
||||
}
|
||||
}
|
||||
+52
-18
@@ -1,10 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ts from 'typescript'
|
||||
import { compareWebStateToSnapshot } from './actionSync.js'
|
||||
import type { ActionParamsSnapshot } from './actionParamsSnapshot.js'
|
||||
import type { EditableSnapshot } from './editableSnapshot.js'
|
||||
import type { CodifyEdit } from './timelineEdits.js'
|
||||
import { planCodeSync, type CodeSyncInput } from './codeSync.js'
|
||||
import {
|
||||
planCodeSync,
|
||||
type ActionParamsSnapshot,
|
||||
type CodeSyncInput,
|
||||
type OverrideAssessment,
|
||||
type WebStateComparison,
|
||||
} from './codeSync.js'
|
||||
|
||||
/** A one-video comparison in the shape planCodeSync consumes. */
|
||||
function comparisonFor(
|
||||
videoName: string,
|
||||
overrides: OverrideAssessment[]
|
||||
): WebStateComparison {
|
||||
return { videos: [{ videoName, overrides }], snapshotEmpty: false }
|
||||
}
|
||||
|
||||
/** Shorthand for a change-kind assessment on a click/fill option. */
|
||||
function change(
|
||||
selector: string,
|
||||
method: string,
|
||||
optionPath: string,
|
||||
editorValue: unknown
|
||||
): OverrideAssessment {
|
||||
return {
|
||||
kind: 'change',
|
||||
selector,
|
||||
method,
|
||||
occurrence: 0,
|
||||
optionPath,
|
||||
editorValue,
|
||||
}
|
||||
}
|
||||
|
||||
const FILE = '/proj/demo.screenci.ts'
|
||||
const SAVE_SELECTOR = "getByRole('button', { name: 'Save' })"
|
||||
@@ -171,13 +200,18 @@ function afterFor(result: ReturnType<typeof plan>, path: string): string {
|
||||
|
||||
describe('planCodeSync: action-parameter overrides', () => {
|
||||
it('applies change and codify by slug, marks stale unappliable', () => {
|
||||
const comparison = compareWebStateToSnapshot(ACTION_SNAPSHOT, {
|
||||
Demo: {
|
||||
[`${SAVE_SELECTOR}|click|0|move.duration`]: 1200,
|
||||
[`locator('#name')|fill|0|duration`]: 900,
|
||||
[`locator('#gone')|click|0|duration`]: 5,
|
||||
const comparison = comparisonFor('Demo', [
|
||||
change(SAVE_SELECTOR, 'click', 'move.duration', 1200),
|
||||
change("locator('#name')", 'fill', 'duration', 900),
|
||||
{
|
||||
kind: 'stale',
|
||||
selector: "locator('#gone')",
|
||||
method: 'click',
|
||||
occurrence: 0,
|
||||
optionPath: 'duration',
|
||||
editorValue: 5,
|
||||
},
|
||||
})
|
||||
])
|
||||
const result = plan(inputWith({ comparison }))
|
||||
const after = afterFor(result, FILE)
|
||||
expect(after).toContain(
|
||||
@@ -203,9 +237,9 @@ describe('planCodeSync: action-parameter overrides', () => {
|
||||
],
|
||||
},
|
||||
}
|
||||
const comparison = compareWebStateToSnapshot(unstamped, {
|
||||
Demo: { [`${SAVE_SELECTOR}|click|0|move.duration`]: 1200 },
|
||||
})
|
||||
const comparison = comparisonFor('Demo', [
|
||||
change(SAVE_SELECTOR, 'click', 'move.duration', 1200),
|
||||
])
|
||||
const result = plan(inputWith({ comparison, actionSnapshot: unstamped }))
|
||||
expect(result.files).toHaveLength(0)
|
||||
expect(result.unappliable).toHaveLength(1)
|
||||
@@ -226,9 +260,9 @@ describe('planCodeSync: action-parameter overrides', () => {
|
||||
],
|
||||
},
|
||||
}
|
||||
const comparison = compareWebStateToSnapshot(wrongMethod, {
|
||||
Demo: { [`${SAVE_SELECTOR}|click|0|move.duration`]: 1200 },
|
||||
})
|
||||
const comparison = comparisonFor('Demo', [
|
||||
change(SAVE_SELECTOR, 'click', 'move.duration', 1200),
|
||||
])
|
||||
const result = plan(inputWith({ comparison, actionSnapshot: wrongMethod }))
|
||||
expect(result.files).toHaveLength(0)
|
||||
expect(result.unappliable).toHaveLength(1)
|
||||
@@ -920,9 +954,9 @@ describe('planCodeSync: renames', () => {
|
||||
|
||||
describe('planCodeSync: combined edits on one file', () => {
|
||||
it('applies slug-keyed edits of every channel together', () => {
|
||||
const comparison = compareWebStateToSnapshot(ACTION_SNAPSHOT, {
|
||||
Demo: { [`${SAVE_SELECTOR}|click|0|move.duration`]: 1200 },
|
||||
})
|
||||
const comparison = comparisonFor('Demo', [
|
||||
change(SAVE_SELECTOR, 'click', 'move.duration', 1200),
|
||||
])
|
||||
const result = plan(
|
||||
inputWith({
|
||||
comparison,
|
||||
|
||||
+38
-4
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Planner for `screenci sync`: turns the web editor's edits into concrete
|
||||
* Planner for editor codegen: turns the web editor's edits into concrete
|
||||
* text changes to the user's .screenci.ts files, using the codemod primitives.
|
||||
* Driven by `screenci dev` (applyCodegen.ts), which applies each edit to the
|
||||
* sources the moment it arrives; code is the single source of truth.
|
||||
*
|
||||
* Every code edit locates its call site by an action's `editId` slug: an exact
|
||||
* string identity with no heuristics. The model is one linear timeline in call
|
||||
@@ -17,9 +19,11 @@
|
||||
* Pure over injected inputs (parsed comparison, snapshots, a readFile): unit
|
||||
* tests drive it with in-memory files.
|
||||
*/
|
||||
import { jsonEqual, type ActionMethod } from './actionParams.js'
|
||||
import type { ActionParamsSnapshot } from './actionParamsSnapshot.js'
|
||||
import type { WebStateComparison } from './actionSync.js'
|
||||
import {
|
||||
jsonEqual,
|
||||
type ActionMethod,
|
||||
type ActionParamRecord,
|
||||
} from './actionParams.js'
|
||||
import type { StudioSyncState } from './studioSync.js'
|
||||
import type {
|
||||
CodifyEditsByVideo,
|
||||
@@ -153,6 +157,36 @@ export type CodeSyncPlan = {
|
||||
fullyAppliedVideos: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest recorded action-parameter records per video, used to locate
|
||||
* stamped call sites for comparison-driven option edits. The codegen path
|
||||
* passes an empty snapshot (the single edit record is the change).
|
||||
*/
|
||||
export type ActionParamsSnapshot = {
|
||||
version: 1
|
||||
videos: Record<string, ActionParamRecord[]>
|
||||
}
|
||||
|
||||
/** How one editor action-parameter override relates to the code. */
|
||||
export type OverrideAssessment = {
|
||||
kind: 'change' | 'remove' | 'codify' | 'in-sync' | 'stale'
|
||||
selector: string
|
||||
method: string
|
||||
occurrence: number
|
||||
optionPath: string
|
||||
editorValue: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor action-parameter override state to reconcile into code. The codegen
|
||||
* path passes an empty comparison; the shape stays so callers can feed
|
||||
* assessed overrides through the same planner.
|
||||
*/
|
||||
export type WebStateComparison = {
|
||||
videos: Array<{ videoName: string; overrides: OverrideAssessment[] }>
|
||||
snapshotEmpty: boolean
|
||||
}
|
||||
|
||||
export type CodeSyncInput = {
|
||||
comparison: WebStateComparison
|
||||
actionSnapshot: ActionParamsSnapshot
|
||||
|
||||
+302
-43
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* Poll loop behind `screenci dev`: registers this machine as a dev listener,
|
||||
* heartbeats via short polling, and runs a record when the web editor asks
|
||||
* for one. All side effects (fetch, sleeping, the actual record run) are
|
||||
* injected so the loop is unit-testable.
|
||||
* heartbeats via short polling, runs records when the web editor asks for
|
||||
* them, and applies editor codegen requests to the test sources. All side
|
||||
* effects (fetch, sleeping, the record run, the codegen apply) are injected
|
||||
* so the loop is unit-testable.
|
||||
*
|
||||
* Records run in a background slot so the poll loop keeps serving codegen
|
||||
* requests while Playwright runs. A new trigger arriving while a record is
|
||||
* active either kills and replaces it (when the run is younger than the kill
|
||||
* window) or queues behind it (queue depth 1, latest wins).
|
||||
*/
|
||||
|
||||
export const DEV_TOKEN_HEADER = 'X-ScreenCI-Dev-Token'
|
||||
@@ -12,6 +18,22 @@ export const DEV_POLL_INTERVAL_MS = 2_500
|
||||
export const DEV_RUN_HEARTBEAT_MS = 10_000
|
||||
/** Back off to this interval while the backend is unreachable. */
|
||||
export const DEV_POLL_ERROR_BACKOFF_MS = 10_000
|
||||
/**
|
||||
* While the editor is actively sending work (codegen requests or triggers),
|
||||
* poll faster so an edit's codegen ack lands well inside the editor's 15s
|
||||
* budget. The window extends on every piece of received work.
|
||||
*/
|
||||
export const DEV_FAST_POLL_INTERVAL_MS = 750
|
||||
export const DEV_FAST_POLL_WINDOW_MS = 60_000
|
||||
/**
|
||||
* A running record younger than this is killed and replaced when a new
|
||||
* trigger arrives; an older one finishes first and the new trigger queues
|
||||
* (queue depth 1, latest wins). Configurable via `screenci dev
|
||||
* --record-kill-window <seconds>`.
|
||||
*/
|
||||
export const DEV_RECORD_KILL_WINDOW_MS = 10_000
|
||||
|
||||
export const SUPERSEDED_RECORD_MESSAGE = 'Superseded by a newer record request'
|
||||
|
||||
export type DevTrigger = {
|
||||
triggerId: string
|
||||
@@ -22,6 +44,25 @@ export type DevTrigger = {
|
||||
previewOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* One editor edit to write into the test source, addressed by editId. The
|
||||
* edit payload is an opaque JSON-encoded unified timeline-edit record; the
|
||||
* CLI's codegen (codeSync/codemod) understands it.
|
||||
*/
|
||||
export type DevCodegenRequest = {
|
||||
requestId: string
|
||||
videoName: string
|
||||
editId: string
|
||||
editJson: string
|
||||
/** True when the edit changes recorded behavior and needs a re-record. */
|
||||
requiresRecord: boolean
|
||||
}
|
||||
|
||||
export type DevPollResult = {
|
||||
trigger: DevTrigger | null
|
||||
codegenRequests: DevCodegenRequest[]
|
||||
}
|
||||
|
||||
export type DevListenLogger = {
|
||||
info: (message: string) => void
|
||||
warn: (message: string) => void
|
||||
@@ -32,10 +73,18 @@ export type DevListenDeps = {
|
||||
fetchFn: typeof fetch
|
||||
sleep: (ms: number) => Promise<void>
|
||||
logger: DevListenLogger
|
||||
/** Runs the actual record for a claimed trigger; throws on failure. */
|
||||
runRecord: (trigger: DevTrigger) => Promise<void>
|
||||
/**
|
||||
* Runs the actual record for a claimed trigger; throws on failure. The
|
||||
* signal aborts the run (kills the Playwright child) when the loop
|
||||
* supersedes it with a newer trigger.
|
||||
*/
|
||||
runRecord: (trigger: DevTrigger, signal?: AbortSignal) => Promise<void>
|
||||
/** Applies one codegen request to the test source; throws on failure. */
|
||||
applyCodegen?: (request: DevCodegenRequest) => Promise<void>
|
||||
/** Registers a heartbeat timer during a run; returns a cancel function. */
|
||||
setIntervalFn?: (fn: () => void, ms: number) => () => void
|
||||
/** Time source, injectable for tests. */
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export type DevListenConfig = {
|
||||
@@ -47,6 +96,9 @@ export type DevListenConfig = {
|
||||
pollIntervalMs?: number
|
||||
errorBackoffMs?: number
|
||||
runHeartbeatMs?: number
|
||||
fastPollIntervalMs?: number
|
||||
fastPollWindowMs?: number
|
||||
recordKillWindowMs?: number
|
||||
}
|
||||
|
||||
/** Thrown when the backend rejects our credentials; the loop must stop. */
|
||||
@@ -112,14 +164,15 @@ export async function pollDevListener(
|
||||
config: DevListenConfig,
|
||||
deps: DevListenDeps,
|
||||
listenerId: string
|
||||
): Promise<DevTrigger | null> {
|
||||
const result = await postDev<{ trigger?: DevTrigger | null }>(
|
||||
config,
|
||||
deps,
|
||||
'/cli/dev/poll',
|
||||
{ listenerId }
|
||||
)
|
||||
return result.trigger ?? null
|
||||
): Promise<DevPollResult> {
|
||||
const result = await postDev<{
|
||||
trigger?: DevTrigger | null
|
||||
codegenRequests?: DevCodegenRequest[] | null
|
||||
}>(config, deps, '/cli/dev/poll', { listenerId })
|
||||
return {
|
||||
trigger: result.trigger ?? null,
|
||||
codegenRequests: result.codegenRequests ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function reportDevTrigger(
|
||||
@@ -138,6 +191,39 @@ export async function reportDevTrigger(
|
||||
})
|
||||
}
|
||||
|
||||
export async function reportDevCodegen(
|
||||
config: DevListenConfig,
|
||||
deps: DevListenDeps,
|
||||
listenerId: string,
|
||||
requestId: string,
|
||||
state: 'applied' | 'failed',
|
||||
errorMessage?: string
|
||||
): Promise<void> {
|
||||
await postDev(config, deps, '/cli/dev/report-codegen', {
|
||||
listenerId,
|
||||
requestId,
|
||||
state,
|
||||
...(errorMessage !== undefined ? { errorMessage } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports which videos this listener is currently bringing up to date (the
|
||||
* startup handshake's stale set). The editor locks those videos' timelines
|
||||
* until the list is cleared.
|
||||
*/
|
||||
export async function reportDevSyncState(
|
||||
config: DevListenConfig,
|
||||
deps: DevListenDeps,
|
||||
listenerId: string,
|
||||
syncingVideoNames: string[]
|
||||
): Promise<void> {
|
||||
await postDev(config, deps, '/cli/dev/sync-state', {
|
||||
listenerId,
|
||||
syncingVideoNames,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deregisterDevListener(
|
||||
config: DevListenConfig,
|
||||
deps: DevListenDeps,
|
||||
@@ -146,48 +232,55 @@ export async function deregisterDevListener(
|
||||
await postDev(config, deps, '/cli/dev/deregister', { listenerId })
|
||||
}
|
||||
|
||||
async function handleTrigger(
|
||||
/** One background record run. */
|
||||
type ActiveRecord = {
|
||||
trigger: DevTrigger
|
||||
startedAt: number
|
||||
abort: AbortController
|
||||
/** Set before aborting so the completion reports "superseded", not "done". */
|
||||
superseded: boolean
|
||||
done: Promise<void>
|
||||
}
|
||||
|
||||
async function handleCodegenRequest(
|
||||
config: DevListenConfig,
|
||||
deps: DevListenDeps,
|
||||
listenerId: string,
|
||||
trigger: DevTrigger
|
||||
request: DevCodegenRequest
|
||||
): Promise<void> {
|
||||
const setIntervalFn = deps.setIntervalFn ?? defaultSetInterval
|
||||
deps.logger.info(
|
||||
`Record requested by ${trigger.requestedByName}: "${trigger.videoName}" (${trigger.language})`
|
||||
)
|
||||
await reportDevTrigger(config, deps, listenerId, trigger.triggerId, 'running')
|
||||
|
||||
// Re-report `running` periodically so a long record keeps the listener's
|
||||
// heartbeat fresh; best-effort, a missed beat only delays the status UI.
|
||||
const cancelHeartbeat = setIntervalFn(() => {
|
||||
void reportDevTrigger(
|
||||
if (deps.applyCodegen === undefined) {
|
||||
await reportDevCodegen(
|
||||
config,
|
||||
deps,
|
||||
listenerId,
|
||||
trigger.triggerId,
|
||||
'running'
|
||||
).catch(() => {})
|
||||
}, config.runHeartbeatMs ?? DEV_RUN_HEARTBEAT_MS)
|
||||
|
||||
request.requestId,
|
||||
'failed',
|
||||
'This listener does not support codegen'
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deps.runRecord(trigger)
|
||||
cancelHeartbeat()
|
||||
await reportDevTrigger(config, deps, listenerId, trigger.triggerId, 'done')
|
||||
await deps.applyCodegen(request)
|
||||
await reportDevCodegen(
|
||||
config,
|
||||
deps,
|
||||
listenerId,
|
||||
request.requestId,
|
||||
'applied'
|
||||
)
|
||||
deps.logger.info(
|
||||
`Finished recording "${trigger.videoName}" (${trigger.language}).`
|
||||
`Applied edit "${request.editId}" to "${request.videoName}".`
|
||||
)
|
||||
} catch (error) {
|
||||
cancelHeartbeat()
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
deps.logger.error(
|
||||
`Record for "${trigger.videoName}" (${trigger.language}) failed: ${message}`
|
||||
`Codegen for edit "${request.editId}" (${request.videoName}) failed: ${message}`
|
||||
)
|
||||
await reportDevTrigger(
|
||||
await reportDevCodegen(
|
||||
config,
|
||||
deps,
|
||||
listenerId,
|
||||
trigger.triggerId,
|
||||
request.requestId,
|
||||
'failed',
|
||||
message
|
||||
)
|
||||
@@ -211,21 +304,187 @@ export async function runDevListenLoop(
|
||||
): Promise<void> {
|
||||
const pollIntervalMs = config.pollIntervalMs ?? DEV_POLL_INTERVAL_MS
|
||||
const errorBackoffMs = config.errorBackoffMs ?? DEV_POLL_ERROR_BACKOFF_MS
|
||||
const fastPollIntervalMs =
|
||||
config.fastPollIntervalMs ?? DEV_FAST_POLL_INTERVAL_MS
|
||||
const fastPollWindowMs = config.fastPollWindowMs ?? DEV_FAST_POLL_WINDOW_MS
|
||||
const killWindowMs = config.recordKillWindowMs ?? DEV_RECORD_KILL_WINDOW_MS
|
||||
const setIntervalFn = deps.setIntervalFn ?? defaultSetInterval
|
||||
const now = deps.now ?? Date.now
|
||||
|
||||
let active: ActiveRecord | null = null
|
||||
let queued: DevTrigger | null = null
|
||||
let lastActivityAt = -Infinity
|
||||
|
||||
const startRecord = (trigger: DevTrigger): void => {
|
||||
const abort = new AbortController()
|
||||
const slot: ActiveRecord = {
|
||||
trigger,
|
||||
startedAt: now(),
|
||||
abort,
|
||||
superseded: false,
|
||||
done: Promise.resolve(),
|
||||
}
|
||||
active = slot
|
||||
slot.done = (async () => {
|
||||
deps.logger.info(
|
||||
`Record requested by ${trigger.requestedByName}: "${trigger.videoName}" (${trigger.language})`
|
||||
)
|
||||
let cancelHeartbeat = () => {}
|
||||
try {
|
||||
await reportDevTrigger(
|
||||
config,
|
||||
deps,
|
||||
listenerId,
|
||||
trigger.triggerId,
|
||||
'running'
|
||||
)
|
||||
// Re-report `running` periodically so a long record keeps the
|
||||
// listener's heartbeat fresh; best-effort, a missed beat only delays
|
||||
// the status UI.
|
||||
cancelHeartbeat = setIntervalFn(() => {
|
||||
void reportDevTrigger(
|
||||
config,
|
||||
deps,
|
||||
listenerId,
|
||||
trigger.triggerId,
|
||||
'running'
|
||||
).catch(() => {})
|
||||
}, config.runHeartbeatMs ?? DEV_RUN_HEARTBEAT_MS)
|
||||
|
||||
await deps.runRecord(trigger, abort.signal)
|
||||
cancelHeartbeat()
|
||||
if (slot.superseded) {
|
||||
await reportDevTrigger(
|
||||
config,
|
||||
deps,
|
||||
listenerId,
|
||||
trigger.triggerId,
|
||||
'failed',
|
||||
SUPERSEDED_RECORD_MESSAGE
|
||||
)
|
||||
} else {
|
||||
await reportDevTrigger(
|
||||
config,
|
||||
deps,
|
||||
listenerId,
|
||||
trigger.triggerId,
|
||||
'done'
|
||||
)
|
||||
deps.logger.info(
|
||||
`Finished recording "${trigger.videoName}" (${trigger.language}).`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
cancelHeartbeat()
|
||||
const message = slot.superseded
|
||||
? SUPERSEDED_RECORD_MESSAGE
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error)
|
||||
if (!slot.superseded) {
|
||||
deps.logger.error(
|
||||
`Record for "${trigger.videoName}" (${trigger.language}) failed: ${message}`
|
||||
)
|
||||
}
|
||||
await reportDevTrigger(
|
||||
config,
|
||||
deps,
|
||||
listenerId,
|
||||
trigger.triggerId,
|
||||
'failed',
|
||||
message
|
||||
).catch(() => {})
|
||||
} finally {
|
||||
if (active === slot) active = null
|
||||
const next = queued
|
||||
queued = null
|
||||
if (next !== null && !controller.stopped) startRecord(next)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const acceptTrigger = async (trigger: DevTrigger): Promise<void> => {
|
||||
if (active === null) {
|
||||
startRecord(trigger)
|
||||
return
|
||||
}
|
||||
// Latest wins: a previously queued trigger is dropped for the new one.
|
||||
if (queued !== null) {
|
||||
await reportDevTrigger(
|
||||
config,
|
||||
deps,
|
||||
listenerId,
|
||||
queued.triggerId,
|
||||
'failed',
|
||||
SUPERSEDED_RECORD_MESSAGE
|
||||
).catch(() => {})
|
||||
}
|
||||
queued = trigger
|
||||
if (now() - active.startedAt < killWindowMs) {
|
||||
// Young run: kill it, the slot's completion starts the queued trigger.
|
||||
deps.logger.info(
|
||||
`Killing the record of "${active.trigger.videoName}" for a newer request.`
|
||||
)
|
||||
active.superseded = true
|
||||
active.abort.abort()
|
||||
} else {
|
||||
deps.logger.info(
|
||||
`Queued "${trigger.videoName}" after the record in progress.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
while (!controller.stopped) {
|
||||
let delayMs = pollIntervalMs
|
||||
try {
|
||||
const trigger = await pollDevListener(config, deps, listenerId)
|
||||
if (trigger && !controller.stopped) {
|
||||
await handleTrigger(config, deps, listenerId, trigger)
|
||||
const result = await pollDevListener(config, deps, listenerId)
|
||||
if (result.codegenRequests.length > 0 || result.trigger !== null) {
|
||||
lastActivityAt = now()
|
||||
}
|
||||
// Codegen requests apply serially (the codemod edits source files) and
|
||||
// while a record runs in the background slot, so an edit's ack never
|
||||
// waits behind Playwright.
|
||||
for (const request of result.codegenRequests) {
|
||||
if (controller.stopped) break
|
||||
await handleCodegenRequest(config, deps, listenerId, request)
|
||||
}
|
||||
if (result.trigger !== null && !controller.stopped) {
|
||||
await acceptTrigger(result.trigger)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DevAuthError) throw error
|
||||
if (error instanceof DevAuthError) {
|
||||
// Cast: `active` is only reassigned inside closures, which
|
||||
// control-flow analysis cannot see.
|
||||
const running = active as ActiveRecord | null
|
||||
if (running !== null) {
|
||||
running.abort.abort()
|
||||
await running.done.catch(() => {})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
deps.logger.warn(`Connection problem, retrying: ${message}`)
|
||||
delayMs = errorBackoffMs
|
||||
}
|
||||
if (controller.stopped) return
|
||||
if (controller.stopped) break
|
||||
if (active !== null) lastActivityAt = now()
|
||||
if (
|
||||
delayMs === pollIntervalMs &&
|
||||
now() - lastActivityAt <= fastPollWindowMs
|
||||
) {
|
||||
delayMs = fastPollIntervalMs
|
||||
}
|
||||
await deps.sleep(delayMs)
|
||||
}
|
||||
|
||||
// Stop requested: kill and settle a record still in flight so the process
|
||||
// never exits with a zombie Playwright child. Not marked superseded: a run
|
||||
// that manages to complete still reports done; a killed one reports failed
|
||||
// with the abort error. (The cast is needed because `active` is only
|
||||
// reassigned inside closures, which control-flow analysis cannot see.)
|
||||
const remaining = active as ActiveRecord | null
|
||||
if (remaining !== null) {
|
||||
remaining.abort.abort()
|
||||
await remaining.done.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
runDevStartupSync,
|
||||
type DevStartupDeps,
|
||||
type KeptRecording,
|
||||
} from './devStartup.js'
|
||||
import type { EditableSnapshotEntry } from './editableSnapshot.js'
|
||||
import type { RecordingData } from './recordingData.js'
|
||||
|
||||
function entry(editId?: string): EditableSnapshotEntry {
|
||||
return {
|
||||
key: editId ?? 'delay|||0',
|
||||
...(editId !== undefined && { editId }),
|
||||
locked: false,
|
||||
defaults: { durationMs: 100 },
|
||||
source: { file: '/proj/demo.screenci.ts', line: 3 },
|
||||
}
|
||||
}
|
||||
|
||||
function kept(
|
||||
videoName: string,
|
||||
sourceHash: string | undefined,
|
||||
entries: EditableSnapshotEntry[]
|
||||
): KeptRecording & { entries: EditableSnapshotEntry[] } {
|
||||
return {
|
||||
entry: `${videoName} [en]`,
|
||||
entries,
|
||||
data: {
|
||||
events: [],
|
||||
renderOptions: {} as RecordingData['renderOptions'],
|
||||
metadata: {
|
||||
videoName,
|
||||
screenciVersion: '0.0.0',
|
||||
sourceFilePath: '/proj/demo.screenci.ts',
|
||||
...(sourceHash !== undefined && { sourceHash }),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
recordings: Array<KeptRecording & { entries: EditableSnapshotEntry[] }>,
|
||||
overrides: Partial<DevStartupDeps> = {}
|
||||
): DevStartupDeps & {
|
||||
stampEditIds: ReturnType<typeof vi.fn>
|
||||
recordPreview: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const byVideo = new Map(
|
||||
recordings.map((r) => [r.data.metadata?.videoName, r])
|
||||
)
|
||||
const stampEditIds = vi.fn(async () => 0)
|
||||
const recordPreview = vi.fn(async () => {})
|
||||
return {
|
||||
readKeptRecordings: async () => recordings,
|
||||
hashSource: async () => 'hash-a',
|
||||
stampEditIds,
|
||||
recordPreview,
|
||||
entriesFromData: (data) =>
|
||||
byVideo.get(data.metadata?.videoName)?.entries ?? [],
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('runDevStartupSync', () => {
|
||||
it('skips recording when everything is fresh and stamped', async () => {
|
||||
const deps = makeDeps([kept('Demo', 'hash-a', [entry('delay1')])])
|
||||
|
||||
const result = await runDevStartupSync({}, deps)
|
||||
|
||||
expect(deps.recordPreview).not.toHaveBeenCalled()
|
||||
expect(result.fresh).toEqual(['Demo'])
|
||||
expect(result.recorded).toEqual([])
|
||||
})
|
||||
|
||||
it('re-records a video whose source hash changed', async () => {
|
||||
const deps = makeDeps([kept('Demo', 'old-hash', [entry('delay1')])])
|
||||
|
||||
const result = await runDevStartupSync({}, deps)
|
||||
|
||||
expect(deps.recordPreview).toHaveBeenCalledWith('Demo')
|
||||
expect(result.recorded).toEqual(['Demo'])
|
||||
})
|
||||
|
||||
it('stamps missing editIds before re-recording', async () => {
|
||||
const calls: string[] = []
|
||||
const deps = makeDeps([kept('Demo', 'hash-a', [entry()])], {})
|
||||
deps.stampEditIds.mockImplementation(async () => {
|
||||
calls.push('stamp')
|
||||
return 1
|
||||
})
|
||||
deps.recordPreview.mockImplementation(async () => {
|
||||
calls.push('record')
|
||||
})
|
||||
|
||||
await runDevStartupSync({}, deps)
|
||||
|
||||
expect(calls[0]).toBe('stamp')
|
||||
expect(calls).toContain('record')
|
||||
expect(deps.stampEditIds).toHaveBeenCalledWith({
|
||||
Demo: [entry()],
|
||||
})
|
||||
})
|
||||
|
||||
it('records everything first when no kept data exists', async () => {
|
||||
const deps = makeDeps([])
|
||||
|
||||
await runDevStartupSync({ grep: 'Intro' }, deps)
|
||||
|
||||
expect(deps.recordPreview).toHaveBeenCalledWith('Intro')
|
||||
})
|
||||
|
||||
it('escapes video names in the re-record grep pattern', async () => {
|
||||
const deps = makeDeps([kept('My Video (v2)', 'stale', [entry('a1')])])
|
||||
|
||||
await runDevStartupSync({}, deps)
|
||||
|
||||
expect(deps.recordPreview).toHaveBeenCalledWith('My Video \\(v2\\)')
|
||||
})
|
||||
|
||||
it('force-records fresh videos when forceRecord is set', async () => {
|
||||
const deps = makeDeps([kept('Demo', 'hash-a', [entry('delay1')])])
|
||||
|
||||
const result = await runDevStartupSync({ forceRecord: true }, deps)
|
||||
|
||||
expect(deps.recordPreview).toHaveBeenCalledWith('Demo')
|
||||
expect(result.recorded).toEqual(['Demo'])
|
||||
})
|
||||
|
||||
it('filters managed videos by grep', async () => {
|
||||
const deps = makeDeps([
|
||||
kept('Intro', 'stale', [entry('a1')]),
|
||||
kept('Outro', 'stale', [entry('b1')]),
|
||||
])
|
||||
|
||||
const result = await runDevStartupSync({ grep: 'Intro' }, deps)
|
||||
|
||||
expect(deps.recordPreview).toHaveBeenCalledWith('Intro')
|
||||
expect(result.recorded).toEqual(['Intro'])
|
||||
})
|
||||
|
||||
it('reports syncing video names around the record pass and clears them', async () => {
|
||||
const calls: string[][] = []
|
||||
const recording = kept('Demo', 'stale', [entry('a1')])
|
||||
const deps = makeDeps([recording], {
|
||||
setSyncing: async (names) => {
|
||||
calls.push(names)
|
||||
},
|
||||
})
|
||||
// The record pass writes a fresh data.json whose hash matches the source.
|
||||
deps.recordPreview.mockImplementation(async () => {
|
||||
recording.data.metadata!.sourceHash = 'hash-a'
|
||||
})
|
||||
|
||||
await runDevStartupSync({}, deps)
|
||||
|
||||
expect(calls).toEqual([['Demo'], []])
|
||||
})
|
||||
|
||||
it('clears the syncing state even when the record pass fails', async () => {
|
||||
const calls: string[][] = []
|
||||
const deps = makeDeps([kept('Demo', 'stale', [entry('a1')])], {
|
||||
setSyncing: async (names) => {
|
||||
calls.push(names)
|
||||
},
|
||||
})
|
||||
deps.recordPreview.mockRejectedValue(new Error('record failed'))
|
||||
|
||||
await expect(runDevStartupSync({}, deps)).rejects.toThrow('record failed')
|
||||
expect(calls).toEqual([['Demo'], []])
|
||||
})
|
||||
|
||||
it('warns about videos whose editIds cannot be stamped (loops)', async () => {
|
||||
// stampEditIds returns 0: nothing could be stamped, entries stay id-less.
|
||||
const deps = makeDeps([kept('Demo', 'hash-a', [entry()])])
|
||||
|
||||
const result = await runDevStartupSync({}, deps)
|
||||
|
||||
expect(result.missingEditIds).toEqual(['Demo'])
|
||||
expect(deps.logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Demo')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* `screenci dev` startup handshake.
|
||||
*
|
||||
* Before the poll loop starts serving the editor, every video this session
|
||||
* manages must be up to date: its kept recording data (data.json preserved
|
||||
* across uploads) must match the current test source (sourceHash) and every
|
||||
* editable action must carry an editId, so editor edits can always be
|
||||
* codegen'd by id. Videos failing the check get their missing editIds
|
||||
* stamped into the source and are re-recorded as a preview (no render).
|
||||
*
|
||||
* A brand-new project has no kept data at all: the first pass records
|
||||
* everything, the second stamps the freshly learned editIds and re-records,
|
||||
* a third verifies. Anything still unstampable afterwards (loop call sites)
|
||||
* is reported and left web-runtime-only.
|
||||
*/
|
||||
import type { EditableSnapshotEntry } from './editableSnapshot.js'
|
||||
import type { RecordingData } from './recordingData.js'
|
||||
import { hashSourceFile, isRecordingFresh } from './recordingFreshness.js'
|
||||
import type { DevListenLogger } from './devListen.js'
|
||||
|
||||
export type KeptRecording = {
|
||||
/** Recording directory name, for logging. */
|
||||
entry: string
|
||||
data: RecordingData
|
||||
}
|
||||
|
||||
export type DevStartupDeps = {
|
||||
/** Reads every kept recording's data (data.json or last-data.json). */
|
||||
readKeptRecordings: () => Promise<KeptRecording[]>
|
||||
/** Hashes a test source file; undefined when unreadable. */
|
||||
hashSource?: (filePath: string) => Promise<string | undefined>
|
||||
/**
|
||||
* Stamps missing editIds into the sources for the given videos' entries.
|
||||
* Returns the number of stamps written.
|
||||
*/
|
||||
stampEditIds: (
|
||||
videos: Record<string, EditableSnapshotEntry[]>
|
||||
) => Promise<number>
|
||||
/**
|
||||
* Records the videos matching the grep pattern (or all when undefined) as
|
||||
* a preview (uploaded to the preview slot, no render) and uploads them.
|
||||
*/
|
||||
recordPreview: (grepPattern: string | undefined) => Promise<void>
|
||||
/** Extracts the per-video editable entries from kept recording data. */
|
||||
entriesFromData: (data: RecordingData) => EditableSnapshotEntry[]
|
||||
/**
|
||||
* Reports the videos currently being brought up to date (the editor locks
|
||||
* their timelines). Called with the stale names before each record pass and
|
||||
* with [] once the handshake finishes. Best-effort.
|
||||
*/
|
||||
setSyncing?: (videoNames: string[]) => Promise<void>
|
||||
logger: DevListenLogger
|
||||
}
|
||||
|
||||
export type DevStartupOptions = {
|
||||
/** Only manage videos whose name matches this pattern (regex, like --grep). */
|
||||
grep?: string
|
||||
/** Re-record everything regardless of freshness. */
|
||||
forceRecord?: boolean
|
||||
}
|
||||
|
||||
export type DevStartupResult = {
|
||||
/** Videos re-recorded during the handshake. */
|
||||
recorded: string[]
|
||||
/** Videos whose kept recording was fresh (recording skipped). */
|
||||
fresh: string[]
|
||||
/** Videos still missing editIds after the handshake (loop call sites). */
|
||||
missingEditIds: string[]
|
||||
}
|
||||
|
||||
function grepMatcher(grep: string | undefined): (name: string) => boolean {
|
||||
if (grep === undefined) return () => true
|
||||
try {
|
||||
const regex = new RegExp(grep)
|
||||
return (name) => regex.test(name)
|
||||
} catch {
|
||||
return (name) => name.includes(grep)
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
type VideoState = {
|
||||
videoName: string
|
||||
entries: EditableSnapshotEntry[]
|
||||
fresh: boolean
|
||||
missingIds: boolean
|
||||
}
|
||||
|
||||
async function collectStates(
|
||||
deps: DevStartupDeps,
|
||||
matches: (name: string) => boolean
|
||||
): Promise<VideoState[]> {
|
||||
const hashSource = deps.hashSource ?? hashSourceFile
|
||||
const states = new Map<string, VideoState>()
|
||||
for (const kept of await deps.readKeptRecordings()) {
|
||||
const videoName = kept.data.metadata?.videoName
|
||||
if (videoName === undefined || !matches(videoName)) continue
|
||||
// Per-language recordings share a videoName; one pass's data suffices.
|
||||
if (states.has(videoName)) continue
|
||||
const sourceFile = kept.data.metadata?.sourceFilePath
|
||||
const currentHash =
|
||||
sourceFile !== undefined ? await hashSource(sourceFile) : undefined
|
||||
const entries = deps.entriesFromData(kept.data)
|
||||
states.set(videoName, {
|
||||
videoName,
|
||||
entries,
|
||||
fresh: isRecordingFresh(kept.data, currentHash),
|
||||
missingIds: entries.some((entry) => entry.editId === undefined),
|
||||
})
|
||||
}
|
||||
return [...states.values()]
|
||||
}
|
||||
|
||||
export async function runDevStartupSync(
|
||||
options: DevStartupOptions,
|
||||
deps: DevStartupDeps
|
||||
): Promise<DevStartupResult> {
|
||||
const matches = grepMatcher(options.grep)
|
||||
const recorded = new Set<string>()
|
||||
let fresh: string[] = []
|
||||
let missingEditIds: string[] = []
|
||||
|
||||
// Worst case (brand-new project): pass 1 records everything to learn the
|
||||
// actions, pass 2 stamps the editIds and re-records, pass 3 verifies.
|
||||
const MAX_PASSES = 3
|
||||
for (let pass = 1; pass <= MAX_PASSES; pass += 1) {
|
||||
const states = await collectStates(deps, matches)
|
||||
|
||||
// No kept data yet: nothing is known about the videos, record everything
|
||||
// this session manages and learn from the produced data.json files.
|
||||
if (states.length === 0) {
|
||||
if (pass === MAX_PASSES) break
|
||||
deps.logger.info(
|
||||
'No kept recordings found, recording to initialize the dev session...'
|
||||
)
|
||||
await deps.recordPreview(options.grep)
|
||||
continue
|
||||
}
|
||||
|
||||
// Stamp missing editIds first so the (re-)record's data.json carries them.
|
||||
const missing = states.filter((state) => state.missingIds)
|
||||
if (missing.length > 0) {
|
||||
const stamps = await deps.stampEditIds(
|
||||
Object.fromEntries(
|
||||
missing.map((state) => [state.videoName, state.entries])
|
||||
)
|
||||
)
|
||||
if (stamps > 0) {
|
||||
deps.logger.info(
|
||||
`Stamped ${stamps} missing editId${stamps === 1 ? '' : 's'} into the sources.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const force = options.forceRecord === true && pass === 1
|
||||
const stale = states.filter(
|
||||
(state) => force || !state.fresh || state.missingIds
|
||||
)
|
||||
fresh = states
|
||||
.filter((state) => !stale.includes(state))
|
||||
.map((state) => state.videoName)
|
||||
missingEditIds = missing.map((state) => state.videoName)
|
||||
|
||||
if (stale.length === 0) break
|
||||
if (pass === MAX_PASSES) {
|
||||
// The second pass's stale set means stamping could not fix everything
|
||||
// (e.g. loop call sites): record once more would not converge.
|
||||
break
|
||||
}
|
||||
|
||||
const names = stale.map((state) => state.videoName)
|
||||
deps.logger.info(
|
||||
`Recording ${names.length} out-of-date video${names.length === 1 ? '' : 's'}: ${names.join(', ')}`
|
||||
)
|
||||
if (deps.setSyncing) await deps.setSyncing(names).catch(() => {})
|
||||
try {
|
||||
await deps.recordPreview(
|
||||
names.map((name) => escapeRegExp(name)).join('|')
|
||||
)
|
||||
} finally {
|
||||
if (deps.setSyncing) await deps.setSyncing([]).catch(() => {})
|
||||
}
|
||||
for (const name of names) recorded.add(name)
|
||||
}
|
||||
|
||||
if (missingEditIds.length > 0) {
|
||||
deps.logger.warn(
|
||||
`Some actions still have no editId (loop call sites cannot be stamped): ${missingEditIds.join(', ')}. Their timings stay web-editable only.`
|
||||
)
|
||||
}
|
||||
if (recorded.size === 0 && fresh.length > 0) {
|
||||
deps.logger.info(
|
||||
'All recordings are up to date with the test sources, skipping record.'
|
||||
)
|
||||
}
|
||||
|
||||
return { recorded: [...recorded], fresh, missingEditIds }
|
||||
}
|
||||
+16
-177
@@ -1,76 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
applyEditableOverride,
|
||||
resolveRuntimeOverridesForVideo,
|
||||
} from './editableRuntime.js'
|
||||
import { SCREENCI_TIMELINE_EDITS_ENV } from './timelineEdits.js'
|
||||
import { applyEditableOverride } from './editableRuntime.js'
|
||||
import { buildEditableMeta } from './editableDescriptor.js'
|
||||
import {
|
||||
createScreenCIRuntimeContext,
|
||||
runWithScreenCIRuntimeContext,
|
||||
} from './runtimeContext.js'
|
||||
|
||||
function env(value: string): NodeJS.ProcessEnv {
|
||||
return { [SCREENCI_TIMELINE_EDITS_ENV]: value }
|
||||
}
|
||||
|
||||
const paramEdit = (key: string, fields: Record<string, unknown>) => ({
|
||||
type: 'paramEdit',
|
||||
id: `param|${key}`,
|
||||
target: { key },
|
||||
fields,
|
||||
})
|
||||
|
||||
/** Overrides map in the shape applyEditableOverride consumes. */
|
||||
function overridesByKey(
|
||||
entries: Array<{ key: string; values: Record<string, unknown> }>
|
||||
): Map<string, Record<string, unknown>> {
|
||||
return new Map(entries.map((entry) => [entry.key, entry.values]))
|
||||
}
|
||||
|
||||
describe('resolveRuntimeOverridesForVideo', () => {
|
||||
it('indexes the unified doc param edits for the requested video only', () => {
|
||||
const e = env(
|
||||
JSON.stringify({
|
||||
a: { version: 2, edits: [paramEdit('speed||0', { multiplier: 2 })] },
|
||||
b: { version: 2, edits: [paramEdit('speed||0', { multiplier: 4 })] },
|
||||
})
|
||||
)
|
||||
const map = resolveRuntimeOverridesForVideo('a', e)
|
||||
expect(map?.get('speed||0')).toEqual({ multiplier: 2 })
|
||||
expect(resolveRuntimeOverridesForVideo('missing', e)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when unset, malformed, or without param edits', () => {
|
||||
expect(resolveRuntimeOverridesForVideo('a', {})).toBeNull()
|
||||
expect(resolveRuntimeOverridesForVideo('a', env('not json'))).toBeNull()
|
||||
expect(
|
||||
resolveRuntimeOverridesForVideo(
|
||||
'a',
|
||||
env(
|
||||
JSON.stringify({
|
||||
a: {
|
||||
version: 2,
|
||||
edits: [
|
||||
{
|
||||
type: 'placedEvent',
|
||||
id: 'e1',
|
||||
kind: 'hide',
|
||||
anchor: {
|
||||
ref: { type: 'videoStart' },
|
||||
edge: 'start',
|
||||
offsetMs: 0,
|
||||
},
|
||||
end: { durationMs: 100 },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyEditableOverride', () => {
|
||||
const meta = () =>
|
||||
@@ -84,113 +14,22 @@ describe('applyEditableOverride', () => {
|
||||
position: { seq: 0, ordinal: 0 },
|
||||
})
|
||||
|
||||
it('merges the override over defaults and stamps applied', () => {
|
||||
it('returns the code-declared defaults', () => {
|
||||
const m = meta()
|
||||
const overrides = overridesByKey([
|
||||
{
|
||||
key: 'input|click|getByRole(button)|0',
|
||||
values: { moveDuration: 250, unknownField: 1 },
|
||||
},
|
||||
])
|
||||
const merged = applyEditableOverride(m, overrides)
|
||||
expect(merged).toEqual({ moveDuration: 250, moveEasing: 'ease-in-out' })
|
||||
expect(m.applied).toEqual({ moveDuration: 250 })
|
||||
})
|
||||
|
||||
it('skips a field equal to the default (no no-op override)', () => {
|
||||
const m = meta()
|
||||
const warnings: string[] = []
|
||||
// moveDuration equals the default (900): a no-op that must not be applied,
|
||||
// logged, or reported. moveEasing genuinely changes.
|
||||
const overrides = overridesByKey([
|
||||
{
|
||||
key: 'input|click|getByRole(button)|0',
|
||||
values: { moveDuration: 900, moveEasing: 'linear' },
|
||||
},
|
||||
])
|
||||
const merged = applyEditableOverride(m, overrides, (message) =>
|
||||
warnings.push(message)
|
||||
)
|
||||
expect(merged).toEqual({ moveDuration: 900, moveEasing: 'linear' })
|
||||
// Only the field that actually changed is recorded as applied.
|
||||
expect(m.applied).toEqual({ moveEasing: 'linear' })
|
||||
})
|
||||
|
||||
it('applies nothing when every field equals the default', () => {
|
||||
const m = meta()
|
||||
const overrides = overridesByKey([
|
||||
{
|
||||
key: 'input|click|getByRole(button)|0',
|
||||
values: { moveDuration: 900, moveEasing: 'ease-in-out' },
|
||||
},
|
||||
])
|
||||
expect(applyEditableOverride(m, overrides)).toEqual(m.defaults)
|
||||
expect(m.applied).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never applies keys outside the defaults', () => {
|
||||
const m = meta()
|
||||
const overrides = overridesByKey([
|
||||
{ key: 'input|click|getByRole(button)|0', values: { evil: true } },
|
||||
])
|
||||
expect(applyEditableOverride(m, overrides)).toEqual(m.defaults)
|
||||
expect(m.applied).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns plain defaults for missing overrides', () => {
|
||||
const overrides = overridesByKey([
|
||||
{ key: 'input|click|getByRole(button)|0', values: { moveDuration: 1 } },
|
||||
])
|
||||
expect(applyEditableOverride(meta(), new Map())).toEqual(meta().defaults)
|
||||
expect(applyEditableOverride(undefined, overrides)).toEqual({})
|
||||
})
|
||||
|
||||
it('applies overrides over explicit code values and warns per shadowed field', () => {
|
||||
const locked = {
|
||||
...meta(),
|
||||
locked: true,
|
||||
lockedFields: ['moveDuration'],
|
||||
}
|
||||
const overrides = overridesByKey([
|
||||
{
|
||||
key: 'input|click|getByRole(button)|0',
|
||||
values: { moveDuration: 1, moveEasing: 'linear' },
|
||||
},
|
||||
])
|
||||
const warnings: string[] = []
|
||||
const merged = applyEditableOverride(locked, overrides, (message) =>
|
||||
warnings.push(message)
|
||||
)
|
||||
expect(merged).toEqual({ moveDuration: 1, moveEasing: 'linear' })
|
||||
expect(locked.applied).toEqual({ moveDuration: 1, moveEasing: 'linear' })
|
||||
// Only the explicitly code-set field warns; the defaulted easing does not.
|
||||
expect(warnings).toHaveLength(1)
|
||||
expect(warnings[0]).toContain('shadows code value')
|
||||
expect(warnings[0]).toContain('moveDuration')
|
||||
expect(warnings[0]).toContain('900')
|
||||
})
|
||||
|
||||
it('treats every field of a locked meta without lockedFields as explicit', () => {
|
||||
const locked = { ...meta(), locked: true }
|
||||
const overrides = overridesByKey([
|
||||
{ key: 'input|click|getByRole(button)|0', values: { moveDuration: 1 } },
|
||||
])
|
||||
const warnings: string[] = []
|
||||
const merged = applyEditableOverride(locked, overrides, (message) =>
|
||||
warnings.push(message)
|
||||
)
|
||||
expect(merged).toMatchObject({ moveDuration: 1 })
|
||||
expect(warnings).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reads overrides from the runtime context by default', () => {
|
||||
const context = createScreenCIRuntimeContext()
|
||||
context.editable.overridesByKey = overridesByKey([
|
||||
{ key: 'input|click|getByRole(button)|0', values: { moveDuration: 111 } },
|
||||
])
|
||||
runWithScreenCIRuntimeContext(context, () => {
|
||||
const m = meta()
|
||||
expect(applyEditableOverride(m)).toMatchObject({ moveDuration: 111 })
|
||||
expect(applyEditableOverride(m)).toEqual({
|
||||
moveDuration: 900,
|
||||
moveEasing: 'ease-in-out',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a copy, never the meta defaults object itself', () => {
|
||||
const m = meta()
|
||||
const values = applyEditableOverride(m)
|
||||
values['moveDuration'] = 1
|
||||
expect(m.defaults['moveDuration']).toBe(900)
|
||||
})
|
||||
|
||||
it('returns an empty object without meta', () => {
|
||||
expect(applyEditableOverride(undefined)).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
+9
-105
@@ -1,115 +1,19 @@
|
||||
/**
|
||||
* Runtime application of web-editor overrides for editable actions.
|
||||
*
|
||||
* Before a recording, the CLI fetches the video's stored timeline edits from
|
||||
* the backend and injects them via `SCREENCI_TIMELINE_EDITS`. At runtime each
|
||||
* editable action resolves its param edit by stable key and merges it over
|
||||
* the effective defaults, so a web edit changes the very next record without
|
||||
* a code change. Placed events from the same doc apply at data.json write
|
||||
* time (see timelineEdits.ts).
|
||||
* Effective values of editable actions at runtime. Code is the single source
|
||||
* of truth: the web editor's edits are codegen'd into the .screenci.ts
|
||||
* sources by `screenci dev` (see applyCodegen.ts), so a recording always runs
|
||||
* with the code-declared values; nothing is overridden at record time.
|
||||
*/
|
||||
import type { EditableMeta } from './editableDescriptor.js'
|
||||
import { stableEditableKey } from './editableDescriptor.js'
|
||||
import { jsonEqual } from './actionParams.js'
|
||||
import {
|
||||
getEditableRunOverrides,
|
||||
getEditableRunReport,
|
||||
} from './runtimeContext.js'
|
||||
import { isOverrideDebugEnabled } from './debugFlags.js'
|
||||
import type { OverrideReportBuilder } from './timelineEdits.js'
|
||||
import { resolveTimelineEditsForVideo, splitEdits } from './timelineEdits.js'
|
||||
|
||||
/**
|
||||
* Merges the web override for the given editable action over its effective
|
||||
* defaults and returns the values the action should run with. Also stamps
|
||||
* `meta.applied` with the override so the recording documents what was used.
|
||||
*
|
||||
* Overrides apply to explicit code values too: when an overridden field is in
|
||||
* `meta.lockedFields` (or the action is marked `locked`), the override still
|
||||
* wins but a warning explains that it shadows a value set in code. Only keys
|
||||
* already present in `defaults` are applied: a stale override field from an
|
||||
* older schema can never inject an unknown option.
|
||||
* The values an editable action runs with: its effective defaults straight
|
||||
* from code (explicit call-site values merged over package defaults by the
|
||||
* instrumentation that built `meta`).
|
||||
*/
|
||||
export function applyEditableOverride(
|
||||
meta: EditableMeta | undefined,
|
||||
overridesByKey: Map<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
> | null = getEditableRunOverrides(),
|
||||
warn: (message: string) => void = (message) => console.warn(message),
|
||||
report: OverrideReportBuilder | null = getEditableRunReport()
|
||||
meta: EditableMeta | undefined
|
||||
): Record<string, unknown> {
|
||||
if (meta === undefined) return {}
|
||||
if (overridesByKey === null) return { ...meta.defaults }
|
||||
|
||||
const key = stableEditableKey(meta.descriptor)
|
||||
const override = overridesByKey.get(key)
|
||||
if (override === undefined) return { ...meta.defaults }
|
||||
|
||||
const lockedFields = new Set(
|
||||
meta.lockedFields ?? (meta.locked ? Object.keys(meta.defaults) : [])
|
||||
)
|
||||
const applied: Record<string, unknown> = {}
|
||||
const shadowed: Record<string, unknown> = {}
|
||||
const merged: Record<string, unknown> = { ...meta.defaults }
|
||||
for (const [field, value] of Object.entries(override)) {
|
||||
if (value === undefined) continue
|
||||
// Unknown fields never inject options, with one exception: `sleepBefore`
|
||||
// applies to recordings made before the field existed in defaults, so a
|
||||
// web start-time edit works without an intermediate re-record.
|
||||
if (!(field in meta.defaults) && field !== 'sleepBefore') continue
|
||||
// A field equal to the recorded default changes nothing: skip it so it is
|
||||
// never merged, logged, or reported as a no-op `x -> x` override. (The
|
||||
// editor prunes these on save; this also covers docs stored before that.)
|
||||
if (jsonEqual(meta.defaults[field], value)) continue
|
||||
if (lockedFields.has(field) && meta.defaults[field] !== value) {
|
||||
shadowed[field] = meta.defaults[field]
|
||||
warn(
|
||||
`[screenci] editor override shadows code value: ${key} ${field}: ` +
|
||||
`code ${JSON.stringify(meta.defaults[field])} -> editor ` +
|
||||
`${JSON.stringify(value)}. Run \`screenci status\` to reconcile.`
|
||||
)
|
||||
}
|
||||
merged[field] = value
|
||||
applied[field] = value
|
||||
if (isOverrideDebugEnabled()) {
|
||||
warn(
|
||||
`[screenci debug] editor override applied: ${key} ${field}: ` +
|
||||
`${JSON.stringify(meta.defaults[field])} -> ${JSON.stringify(value)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (Object.keys(applied).length > 0) {
|
||||
meta.applied = applied
|
||||
report?.add({
|
||||
editId: key,
|
||||
channel: 'paramEdit',
|
||||
status: Object.keys(shadowed).length > 0 ? 'shadowed-code' : 'applied',
|
||||
subject: key,
|
||||
appliedValues: applied,
|
||||
...(Object.keys(shadowed).length > 0 && { codeValues: shadowed }),
|
||||
})
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime overrides for one video: the unified timeline-edits doc's param
|
||||
* edits (`SCREENCI_TIMELINE_EDITS`), indexed by stable key. Only param edits
|
||||
* apply at runtime; placed events apply at data.json write time. Null when
|
||||
* nothing was injected for the video.
|
||||
*/
|
||||
export function resolveRuntimeOverridesForVideo(
|
||||
videoName: string,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): Map<string, Record<string, unknown>> | null {
|
||||
const unified = resolveTimelineEditsForVideo(videoName, env)
|
||||
if (unified === null) return null
|
||||
const { paramEdits } = splitEdits(unified.edits)
|
||||
if (paramEdits.length === 0) return null
|
||||
const byKey = new Map<string, Record<string, unknown>>()
|
||||
for (const edit of paramEdits) {
|
||||
byKey.set(edit.target.key, edit.fields)
|
||||
}
|
||||
return byKey
|
||||
return { ...meta.defaults }
|
||||
}
|
||||
|
||||
@@ -4,115 +4,9 @@ import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectEditableFromRecordings,
|
||||
diffEditableOverridesAgainstSnapshot,
|
||||
formatEditableStatusReport,
|
||||
splitTimelineEditsByVideo,
|
||||
mergeEditableSnapshot,
|
||||
readEditableSnapshot,
|
||||
updateEditableSnapshot,
|
||||
writeEditableSnapshot,
|
||||
type EditableSnapshot,
|
||||
} from './editableSnapshot.js'
|
||||
|
||||
const SNAPSHOT: EditableSnapshot = {
|
||||
version: 1,
|
||||
videos: {
|
||||
'My video': [
|
||||
{
|
||||
key: 'input|click|getByRole(button)|0',
|
||||
locked: true,
|
||||
lockedFields: ['moveDuration'],
|
||||
defaults: { moveDuration: 400, moveEasing: 'ease-in-out' },
|
||||
},
|
||||
{
|
||||
key: 'autoZoom|||0',
|
||||
locked: false,
|
||||
defaults: { amount: 0.72 },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
describe('diffEditableOverridesAgainstSnapshot', () => {
|
||||
it('reports only overrides shadowing explicitly code-set fields', () => {
|
||||
const collisions = diffEditableOverridesAgainstSnapshot(SNAPSHOT, {
|
||||
'My video': [
|
||||
{
|
||||
key: 'input|click|getByRole(button)|0',
|
||||
values: { moveDuration: 250, moveEasing: 'linear' },
|
||||
},
|
||||
{ key: 'autoZoom|||0', values: { amount: 0.5 } },
|
||||
],
|
||||
})
|
||||
expect(collisions).toEqual([
|
||||
{
|
||||
videoName: 'My video',
|
||||
key: 'input|click|getByRole(button)|0',
|
||||
field: 'moveDuration',
|
||||
codeValue: 400,
|
||||
editorValue: 250,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('treats a locked entry without lockedFields as all-explicit', () => {
|
||||
const snapshot: EditableSnapshot = {
|
||||
version: 1,
|
||||
videos: {
|
||||
v: [{ key: 'delay|||0', locked: true, defaults: { durationMs: 500 } }],
|
||||
},
|
||||
}
|
||||
const collisions = diffEditableOverridesAgainstSnapshot(snapshot, {
|
||||
v: [{ key: 'delay|||0', values: { durationMs: 100 } }],
|
||||
})
|
||||
expect(collisions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignores unknown videos, unknown keys and equal values', () => {
|
||||
expect(
|
||||
diffEditableOverridesAgainstSnapshot(SNAPSHOT, {
|
||||
Other: [{ key: 'x', values: { a: 1 } }],
|
||||
'My video': [
|
||||
{ key: 'missing', values: { a: 1 } },
|
||||
{
|
||||
key: 'input|click|getByRole(button)|0',
|
||||
values: { moveDuration: 400 },
|
||||
},
|
||||
],
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatEditableStatusReport', () => {
|
||||
it('classifies shadowing, default-changing, in-sync and stale overrides', () => {
|
||||
const lines = formatEditableStatusReport(SNAPSHOT, {
|
||||
'My video': [
|
||||
{
|
||||
key: 'input|click|getByRole(button)|0',
|
||||
values: { moveDuration: 250, moveEasing: 'linear' },
|
||||
},
|
||||
{ key: 'autoZoom|||0', values: { amount: 0.72 } },
|
||||
{ key: 'gone|||0', values: { x: 1 } },
|
||||
],
|
||||
})
|
||||
const report = lines.join('\n')
|
||||
expect(report).toContain('Video: My video')
|
||||
expect(report).toContain(
|
||||
'moveDuration: override shadows explicit code value'
|
||||
)
|
||||
expect(report).toContain('code 400 -> editor 250')
|
||||
expect(report).toContain('moveEasing: changes default')
|
||||
expect(report).toContain('amount: in sync')
|
||||
expect(report).toContain('gone|||0: stale')
|
||||
})
|
||||
|
||||
it('returns no lines without overrides', () => {
|
||||
expect(formatEditableStatusReport(SNAPSHOT, {})).toEqual([])
|
||||
expect(formatEditableStatusReport(SNAPSHOT, { v: [] })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitTimelineEditsByVideo', () => {
|
||||
it('splits docs into param-edit entries and codify records', () => {
|
||||
const { overrides, codify, renames } = splitTimelineEditsByVideo({
|
||||
@@ -159,7 +53,7 @@ describe('splitTimelineEditsByVideo', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('snapshot file round-trip', () => {
|
||||
describe('collectEditableFromRecordings', () => {
|
||||
let dir: string
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'screenci-editable-'))
|
||||
@@ -168,12 +62,6 @@ describe('snapshot file round-trip', () => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('reads back what it wrote and tolerates a missing file', () => {
|
||||
expect(readEditableSnapshot(dir)).toEqual({ version: 1, videos: {} })
|
||||
writeEditableSnapshot(dir, SNAPSHOT)
|
||||
expect(readEditableSnapshot(dir)).toEqual(SNAPSHOT)
|
||||
})
|
||||
|
||||
it('collects editable metas from recording data.json files', () => {
|
||||
const recDir = join(dir, 'My video [en]')
|
||||
mkdirSync(recDir)
|
||||
@@ -212,41 +100,4 @@ describe('snapshot file round-trip', () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('updateEditableSnapshot merges recorded videos over existing ones', () => {
|
||||
writeEditableSnapshot(dir, SNAPSHOT)
|
||||
const recDir = join(dir, 'Other video [en]')
|
||||
mkdirSync(recDir)
|
||||
writeFileSync(
|
||||
join(recDir, 'data.json'),
|
||||
JSON.stringify({
|
||||
metadata: { videoName: 'Other video' },
|
||||
events: [
|
||||
{
|
||||
type: 'delay',
|
||||
editable: {
|
||||
descriptor: { kind: 'delay', ordinal: 0, seq: 0 },
|
||||
locked: false,
|
||||
schemaKind: 'delay',
|
||||
defaults: { durationMs: 0 },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
updateEditableSnapshot(dir)
|
||||
const merged = readEditableSnapshot(dir)
|
||||
expect(Object.keys(merged.videos).sort()).toEqual([
|
||||
'My video',
|
||||
'Other video',
|
||||
])
|
||||
expect(merged.videos['Other video']).toEqual([
|
||||
{ key: 'delay|||0', locked: false, defaults: { durationMs: 0 } },
|
||||
])
|
||||
})
|
||||
|
||||
it('mergeEditableSnapshot keeps videos not recorded this run', () => {
|
||||
const merged = mergeEditableSnapshot(SNAPSHOT, { New: [] })
|
||||
expect(Object.keys(merged.videos).sort()).toEqual(['My video', 'New'])
|
||||
})
|
||||
})
|
||||
|
||||
+34
-198
@@ -1,20 +1,10 @@
|
||||
/**
|
||||
* The `.screenci/editable-actions.json` snapshot: the latest known editable
|
||||
* actions per video (stable key, per-field explicit provenance and effective
|
||||
* defaults), aggregated by the CLI from the per-recording `data.json` files
|
||||
* after every record run. It is never wiped (the record command's directory
|
||||
* clear preserves it) so the next run and `screenci status` can compare the
|
||||
* web editor's timing overrides against the previous run's explicit code
|
||||
* values and warn when an override shadows one.
|
||||
* Editable-action entries collected from the per-recording `data.json` files
|
||||
* (stable key, editId, effective defaults, call-site source). Editor codegen
|
||||
* (`screenci dev`) uses them to locate call sites by editId when writing an
|
||||
* edit into the .screenci.ts sources; code is the single source of truth.
|
||||
*/
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'fs'
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { stableEditableKey } from './editableDescriptor.js'
|
||||
import type {
|
||||
@@ -124,9 +114,6 @@ export function splitTimelineEditsByVideo(
|
||||
return { overrides, codify, removedCodify, renames, overlayDecls }
|
||||
}
|
||||
|
||||
/** File name of the snapshot inside `.screenci`. Preserved across runs. */
|
||||
export const EDITABLE_SNAPSHOT_FILE = 'editable-actions.json'
|
||||
|
||||
/** One editable action as recorded by the previous run. */
|
||||
export type EditableSnapshotEntry = {
|
||||
key: string
|
||||
@@ -144,37 +131,6 @@ export type EditableSnapshot = {
|
||||
videos: Record<string, EditableSnapshotEntry[]>
|
||||
}
|
||||
|
||||
/** A web override that shadows an explicitly code-set editable field. */
|
||||
export type EditableOverrideCollision = {
|
||||
videoName: string
|
||||
key: string
|
||||
field: string
|
||||
codeValue: unknown
|
||||
editorValue: unknown
|
||||
}
|
||||
|
||||
const EMPTY_SNAPSHOT: EditableSnapshot = { version: 1, videos: {} }
|
||||
|
||||
/** Read the snapshot; tolerant of a missing or corrupt file (empty snapshot). */
|
||||
export function readEditableSnapshot(screenciDir: string): EditableSnapshot {
|
||||
const filePath = join(screenciDir, EDITABLE_SNAPSHOT_FILE)
|
||||
if (!existsSync(filePath)) return { ...EMPTY_SNAPSHOT, videos: {} }
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(filePath, 'utf8'))
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
typeof (parsed as { videos?: unknown }).videos !== 'object' ||
|
||||
(parsed as { videos?: unknown }).videos === null
|
||||
) {
|
||||
return { ...EMPTY_SNAPSHOT, videos: {} }
|
||||
}
|
||||
return { version: 1, videos: (parsed as EditableSnapshot).videos }
|
||||
} catch {
|
||||
return { ...EMPTY_SNAPSHOT, videos: {} }
|
||||
}
|
||||
}
|
||||
|
||||
type RecordedEditableMeta = {
|
||||
descriptor?: {
|
||||
kind?: unknown
|
||||
@@ -246,6 +202,26 @@ function toSnapshotEntry(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Editable entries of a single recording's parsed data.json. Shared by the
|
||||
* snapshot collector below and the dev startup handshake (which reads kept
|
||||
* recording data itself).
|
||||
*/
|
||||
export function entriesFromRecordingData(data: {
|
||||
events?: unknown
|
||||
}): EditableSnapshotEntry[] {
|
||||
const events = Array.isArray(data.events) ? data.events : []
|
||||
const entries: EditableSnapshotEntry[] = []
|
||||
for (const event of events) {
|
||||
if (typeof event !== 'object' || event === null) continue
|
||||
const editable = (event as { editable?: unknown }).editable
|
||||
if (typeof editable !== 'object' || editable === null) continue
|
||||
const snapshotEntry = toSnapshotEntry(editable as RecordedEditableMeta)
|
||||
if (snapshotEntry !== null) entries.push(snapshotEntry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect editable actions (keyed by `metadata.videoName`) from every
|
||||
* `.screenci/<recording>/data.json` written by the run that just finished.
|
||||
@@ -256,10 +232,16 @@ export function collectEditableFromRecordings(
|
||||
const collected: Record<string, EditableSnapshotEntry[]> = {}
|
||||
if (!existsSync(screenciDir)) return collected
|
||||
for (const entry of readdirSync(screenciDir)) {
|
||||
const dataPath = join(screenciDir, entry, 'data.json')
|
||||
// Prefer this run's data.json; fall back to the kept last-data.json (a
|
||||
// previous run's event data preserved for the dev freshness check).
|
||||
const candidates = [
|
||||
join(screenciDir, entry, 'data.json'),
|
||||
join(screenciDir, entry, 'last-data.json'),
|
||||
]
|
||||
try {
|
||||
if (!statSync(join(screenciDir, entry)).isDirectory()) continue
|
||||
if (!existsSync(dataPath)) continue
|
||||
const dataPath = candidates.find((path) => existsSync(path))
|
||||
if (dataPath === undefined) continue
|
||||
const parsed: unknown = JSON.parse(readFileSync(dataPath, 'utf8'))
|
||||
if (typeof parsed !== 'object' || parsed === null) continue
|
||||
const data = parsed as {
|
||||
@@ -268,15 +250,7 @@ export function collectEditableFromRecordings(
|
||||
}
|
||||
const videoName = data.metadata?.videoName
|
||||
if (typeof videoName !== 'string') continue
|
||||
const events = Array.isArray(data.events) ? data.events : []
|
||||
const entries: EditableSnapshotEntry[] = []
|
||||
for (const event of events) {
|
||||
if (typeof event !== 'object' || event === null) continue
|
||||
const editable = (event as { editable?: unknown }).editable
|
||||
if (typeof editable !== 'object' || editable === null) continue
|
||||
const snapshotEntry = toSnapshotEntry(editable as RecordedEditableMeta)
|
||||
if (snapshotEntry !== null) entries.push(snapshotEntry)
|
||||
}
|
||||
const entries = entriesFromRecordingData(data)
|
||||
// Per-language passes share one videoName; keep the first pass's
|
||||
// entries (each language performs the same actions).
|
||||
if (!(videoName in collected) || entries.length > 0) {
|
||||
@@ -288,141 +262,3 @@ export function collectEditableFromRecordings(
|
||||
}
|
||||
return collected
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge freshly recorded entries over the existing snapshot. Videos not
|
||||
* recorded this run keep their previous entries (a filtered run must not
|
||||
* lose them).
|
||||
*/
|
||||
export function mergeEditableSnapshot(
|
||||
existing: EditableSnapshot,
|
||||
recorded: Record<string, EditableSnapshotEntry[]>
|
||||
): EditableSnapshot {
|
||||
return { version: 1, videos: { ...existing.videos, ...recorded } }
|
||||
}
|
||||
|
||||
/** Write the snapshot (write-then-rename so a crash never corrupts it). */
|
||||
export function writeEditableSnapshot(
|
||||
screenciDir: string,
|
||||
snapshot: EditableSnapshot
|
||||
): void {
|
||||
const filePath = join(screenciDir, EDITABLE_SNAPSHOT_FILE)
|
||||
const tmpPath = `${filePath}.tmp`
|
||||
writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2))
|
||||
renameSync(tmpPath, filePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the snapshot from the recordings a run just produced: read, merge,
|
||||
* write. Called by the CLI after Playwright exits; best-effort by design (the
|
||||
* caller catches errors).
|
||||
*/
|
||||
export function updateEditableSnapshot(screenciDir: string): void {
|
||||
const recorded = collectEditableFromRecordings(screenciDir)
|
||||
if (Object.keys(recorded).length === 0) return
|
||||
writeEditableSnapshot(
|
||||
screenciDir,
|
||||
mergeEditableSnapshot(readEditableSnapshot(screenciDir), recorded)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find web timing overrides that shadow explicitly code-set values: an
|
||||
* override field listed in the snapshot entry's `lockedFields` (or any field
|
||||
* of a fully `locked` entry) whose value differs from the recorded one. Pure;
|
||||
* a missing snapshot (first run) or unknown keys produce no collisions.
|
||||
*/
|
||||
/**
|
||||
* Human-readable status lines for the web timing overrides, for
|
||||
* `screenci status`. Classifies every stored override field:
|
||||
*
|
||||
* - `shadows code value`: the field is explicit in code and differs
|
||||
* - `changes default`: applies cleanly over a package default
|
||||
* - `stale`: the action (or video) no longer exists in the latest snapshot
|
||||
*
|
||||
* Returns an empty list when there are no overrides at all.
|
||||
*/
|
||||
export function formatEditableStatusReport(
|
||||
snapshot: EditableSnapshot,
|
||||
overridesByVideo: EditableOverridesByVideo
|
||||
): string[] {
|
||||
const lines: string[] = []
|
||||
for (const [videoName, entries] of Object.entries(overridesByVideo)) {
|
||||
if (entries.length === 0) continue
|
||||
lines.push(`Video: ${videoName}`)
|
||||
const snapshotEntries = snapshot.videos[videoName]
|
||||
const byKey = new Map(
|
||||
(snapshotEntries ?? []).map((entry) => [entry.key, entry])
|
||||
)
|
||||
for (const override of entries) {
|
||||
const snapshotEntry = byKey.get(override.key)
|
||||
if (snapshotEntry === undefined) {
|
||||
lines.push(
|
||||
` ${override.key}: stale (not in the latest recording; remove it ` +
|
||||
`in the web editor or re-record)`
|
||||
)
|
||||
continue
|
||||
}
|
||||
const lockedFields = new Set(
|
||||
snapshotEntry.lockedFields ??
|
||||
(snapshotEntry.locked ? Object.keys(snapshotEntry.defaults) : [])
|
||||
)
|
||||
for (const [field, editorValue] of Object.entries(override.values)) {
|
||||
if (editorValue === undefined) continue
|
||||
const codeValue = snapshotEntry.defaults[field]
|
||||
if (codeValue === editorValue) {
|
||||
lines.push(
|
||||
` ${override.key} ${field}: in sync (${JSON.stringify(editorValue)})`
|
||||
)
|
||||
} else if (lockedFields.has(field)) {
|
||||
lines.push(
|
||||
` ${override.key} ${field}: override shadows explicit code ` +
|
||||
`value (code ${JSON.stringify(codeValue)} -> editor ` +
|
||||
`${JSON.stringify(editorValue)}); move it into code or clear ` +
|
||||
`the edit`
|
||||
)
|
||||
} else {
|
||||
lines.push(
|
||||
` ${override.key} ${field}: changes default ` +
|
||||
`(${JSON.stringify(codeValue)} -> ${JSON.stringify(editorValue)})`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
export function diffEditableOverridesAgainstSnapshot(
|
||||
snapshot: EditableSnapshot,
|
||||
overridesByVideo: EditableOverridesByVideo
|
||||
): EditableOverrideCollision[] {
|
||||
const collisions: EditableOverrideCollision[] = []
|
||||
for (const [videoName, entries] of Object.entries(overridesByVideo)) {
|
||||
const snapshotEntries = snapshot.videos[videoName]
|
||||
if (snapshotEntries === undefined) continue
|
||||
const byKey = new Map(snapshotEntries.map((entry) => [entry.key, entry]))
|
||||
for (const override of entries) {
|
||||
const snapshotEntry = byKey.get(override.key)
|
||||
if (snapshotEntry === undefined) continue
|
||||
const lockedFields = new Set(
|
||||
snapshotEntry.lockedFields ??
|
||||
(snapshotEntry.locked ? Object.keys(snapshotEntry.defaults) : [])
|
||||
)
|
||||
for (const [field, editorValue] of Object.entries(override.values)) {
|
||||
if (editorValue === undefined) continue
|
||||
if (!lockedFields.has(field)) continue
|
||||
const codeValue = snapshotEntry.defaults[field]
|
||||
if (codeValue === editorValue) continue
|
||||
collisions.push({
|
||||
videoName,
|
||||
key: override.key,
|
||||
field,
|
||||
codeValue,
|
||||
editorValue,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return collisions
|
||||
}
|
||||
|
||||
+16
-71
@@ -25,13 +25,7 @@ import {
|
||||
type ActionParamSpec,
|
||||
} from './actionParams.js'
|
||||
import type { EditableMeta } from './editableDescriptor.js'
|
||||
import { stableEditableKey } from './editableDescriptor.js'
|
||||
import {
|
||||
OverrideReportBuilder,
|
||||
resolveTimelineEditsForVideo,
|
||||
splitEdits,
|
||||
type OverrideReportItem,
|
||||
} from './timelineEdits.js'
|
||||
import { hashSourceFile } from './recordingFreshness.js'
|
||||
import type { VoiceKey } from './voices.js'
|
||||
import { DEFAULT_ZOOM_OPTIONS } from './defaults.js'
|
||||
import { getGitMetadata } from './git.js'
|
||||
@@ -1197,6 +1191,13 @@ export type RecordingMetadata = {
|
||||
*/
|
||||
availableLanguages?: string[]
|
||||
sourceFilePath?: string
|
||||
/**
|
||||
* SHA-256 of the test source file this recording was produced from. Together
|
||||
* with per-event editIds it lets `screenci dev` skip re-recording when the
|
||||
* source is unchanged (see recordingFreshness.ts). Multiple videos from the
|
||||
* same file share the hash.
|
||||
*/
|
||||
sourceHash?: string
|
||||
/**
|
||||
* Which parts of this recording are web-editor configurable. Every recording
|
||||
* is web-editable, so `renderOptions`/`recordOptions` are always set;
|
||||
@@ -1289,12 +1290,6 @@ export type RecordingData = {
|
||||
* present the parameters for editing and to key its overrides.
|
||||
*/
|
||||
actionParams?: ActionParamRecord[]
|
||||
/**
|
||||
* One item per web-editor override this recording tried to apply, with its
|
||||
* outcome (applied, fallback, shadowed-code, or skipped with a reason). The
|
||||
* backend surfaces these in the editor so no edit ever vanishes silently.
|
||||
*/
|
||||
overrideReport?: OverrideReportItem[]
|
||||
}
|
||||
|
||||
/** Extra, output-specific fields written into `data.json`. */
|
||||
@@ -1605,13 +1600,6 @@ export class EventRecorder implements IEventRecorder {
|
||||
private readonly actionParams: ActionParamCollector
|
||||
/** Monotonic counter for stable `KeyPressEvent.id` values. */
|
||||
private keyPressCounter = 0
|
||||
/**
|
||||
* Report collector shared with the runtime (see runtimeContext.editable):
|
||||
* runtime param edits and write-time placed events land in one report,
|
||||
* embedded into data.json. A fresh builder is created at write time when
|
||||
* none was bound.
|
||||
*/
|
||||
private overrideReport: OverrideReportBuilder | null = null
|
||||
|
||||
constructor(
|
||||
renderOptions?: RenderOptions,
|
||||
@@ -1641,10 +1629,6 @@ export class EventRecorder implements IEventRecorder {
|
||||
return this.actionParams.apply(selector, method, spec, editId)
|
||||
}
|
||||
|
||||
setOverrideReport(report: OverrideReportBuilder): void {
|
||||
this.overrideReport = report
|
||||
}
|
||||
|
||||
registerVoiceForLang(_lang: string, _meta: VoiceLanguageMeta): void {}
|
||||
|
||||
setActiveLanguage(lang: string | null): void {
|
||||
@@ -2647,50 +2631,6 @@ export class EventRecorder implements IEventRecorder {
|
||||
)
|
||||
: this.events
|
||||
|
||||
// Timeline edits: the web editor's edits injected by the CLI via
|
||||
// SCREENCI_TIMELINE_EDITS. Only param edits apply at record time (they
|
||||
// change real waits, applied at runtime by editableRuntime). The codify
|
||||
// records (media/zoom/gap) are never materialized here: `screenci sync`
|
||||
// writes them into code and normal recording emits them. Every param-edit
|
||||
// outcome lands in the override report; nothing fails the recording and
|
||||
// nothing is skipped silently.
|
||||
const report = this.overrideReport ?? new OverrideReportBuilder()
|
||||
const unified = resolveTimelineEditsForVideo(videoName)
|
||||
if (unified !== null) {
|
||||
for (const invalid of unified.invalid) {
|
||||
report.add({
|
||||
editId: invalid.id,
|
||||
channel: 'codifyEdit',
|
||||
status: 'skipped',
|
||||
reason: `invalidRecord:${invalid.reason}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
const webEdits = unified !== null ? splitEdits(unified.edits) : null
|
||||
if (webEdits !== null) {
|
||||
// Param edits whose target action was never recorded this run: report
|
||||
// them so the editor can show the edit went unmatched (stale key).
|
||||
const recordedKeys = new Set<string>()
|
||||
for (const event of serializedEvents) {
|
||||
const meta = (event as { editable?: EditableMeta }).editable
|
||||
if (meta?.descriptor !== undefined) {
|
||||
recordedKeys.add(stableEditableKey(meta.descriptor))
|
||||
}
|
||||
}
|
||||
for (const edit of webEdits.paramEdits) {
|
||||
if (recordedKeys.has(edit.target.key)) continue
|
||||
report.add({
|
||||
editId: edit.id,
|
||||
channel: 'paramEdit',
|
||||
status: 'skipped',
|
||||
subject: edit.target.key,
|
||||
reason: `targetMissing:${edit.target.key}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
report.logSummary(videoName)
|
||||
const overrideReportItems = report.items()
|
||||
|
||||
const languageSet = new Set<string>()
|
||||
for (const event of this.events) {
|
||||
if (event.type === 'cueStart') {
|
||||
@@ -2762,6 +2702,13 @@ export class EventRecorder implements IEventRecorder {
|
||||
}
|
||||
: undefined
|
||||
|
||||
// Source hash: lets the next dev session skip re-recording when the test
|
||||
// file is unchanged and every editable action already has an editId.
|
||||
const sourceHash =
|
||||
sourceFilePath !== undefined
|
||||
? await hashSourceFile(sourceFilePath)
|
||||
: undefined
|
||||
|
||||
const actionParamRecords = this.actionParams.getRecords()
|
||||
const data: RecordingData = {
|
||||
events: serializedEvents,
|
||||
@@ -2776,15 +2723,13 @@ export class EventRecorder implements IEventRecorder {
|
||||
...(actionParamRecords.length > 0 && {
|
||||
actionParams: actionParamRecords,
|
||||
}),
|
||||
...(overrideReportItems.length > 0 && {
|
||||
overrideReport: overrideReportItems,
|
||||
}),
|
||||
metadata: {
|
||||
videoName,
|
||||
screenciVersion: SCREENCI_VERSION,
|
||||
...(languages !== undefined && { languages }),
|
||||
...(availableLanguages !== undefined && { availableLanguages }),
|
||||
...(sourceFilePath !== undefined && { sourceFilePath }),
|
||||
...(sourceHash !== undefined && { sourceHash }),
|
||||
...(git.commit !== undefined && { commit: git.commit }),
|
||||
...(git.isDirty !== undefined && { isDirty: git.isDirty }),
|
||||
...(studio !== undefined && { studio }),
|
||||
|
||||
@@ -366,6 +366,12 @@ export type RecordingMetadata = {
|
||||
screenciVersion: string
|
||||
languages?: string[]
|
||||
sourceFilePath?: string
|
||||
/**
|
||||
* SHA-256 of the test source file this recording was produced from. Used by
|
||||
* the dev-session freshness check (recordingFreshness.ts) to skip recording
|
||||
* when the source is unchanged and every editable action has an editId.
|
||||
*/
|
||||
sourceHash?: string
|
||||
/** First 8 chars of the git commit the recording was made at, if available. */
|
||||
commit?: string
|
||||
/** Whether the repo had uncommitted changes (always false in CI). */
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import {
|
||||
allEventsHaveEditIds,
|
||||
computeSourceHash,
|
||||
hashSourceFile,
|
||||
isRecordingFresh,
|
||||
readKeptRecordingData,
|
||||
LAST_DATA_FILE,
|
||||
} from './recordingFreshness.js'
|
||||
import type { RecordingData } from './recordingData.js'
|
||||
|
||||
function makeData(overrides: Partial<RecordingData> = {}): RecordingData {
|
||||
return {
|
||||
events: [],
|
||||
renderOptions: {} as RecordingData['renderOptions'],
|
||||
metadata: {
|
||||
videoName: 'Demo',
|
||||
screenciVersion: '0.0.0',
|
||||
sourceHash: 'hash-a',
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function editableEvent(editId: string | undefined): unknown {
|
||||
return {
|
||||
type: 'sleep',
|
||||
timeMs: 0,
|
||||
durationMs: 10,
|
||||
reason: 'delay',
|
||||
editable: {
|
||||
descriptor: { kind: 'delay', ordinal: 0, seq: 0, editId },
|
||||
locked: false,
|
||||
schemaKind: 'delay',
|
||||
defaults: { durationMs: 10 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('computeSourceHash', () => {
|
||||
it('is a stable sha256 hex digest of the content', () => {
|
||||
const a = computeSourceHash('content')
|
||||
expect(a).toMatch(/^[0-9a-f]{64}$/)
|
||||
expect(computeSourceHash('content')).toBe(a)
|
||||
expect(computeSourceHash('other')).not.toBe(a)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hashSourceFile', () => {
|
||||
it('hashes the file content via the injected reader', async () => {
|
||||
const hash = await hashSourceFile('/x/test.screenci.ts', async () =>
|
||||
Buffer.from('abc')
|
||||
)
|
||||
expect(hash).toBe(computeSourceHash('abc'))
|
||||
})
|
||||
|
||||
it('resolves undefined when the file cannot be read', async () => {
|
||||
const hash = await hashSourceFile('/missing.ts', async () => {
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
expect(hash).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('allEventsHaveEditIds', () => {
|
||||
it('true for events without editable metadata', () => {
|
||||
const data = makeData({
|
||||
events: [
|
||||
{ type: 'sleep', timeMs: 0, durationMs: 1, reason: 'delay' },
|
||||
] as RecordingData['events'],
|
||||
})
|
||||
expect(allEventsHaveEditIds(data)).toBe(true)
|
||||
})
|
||||
|
||||
it('false when an editable event lacks an editId', () => {
|
||||
const data = makeData({
|
||||
events: [editableEvent(undefined)] as RecordingData['events'],
|
||||
})
|
||||
expect(allEventsHaveEditIds(data)).toBe(false)
|
||||
})
|
||||
|
||||
it('false when an action param record lacks an editId', () => {
|
||||
const data = makeData({
|
||||
events: [editableEvent('delay1')] as RecordingData['events'],
|
||||
actionParams: [
|
||||
{
|
||||
selector: 'locator(form)',
|
||||
method: 'click',
|
||||
occurrence: 0,
|
||||
params: {},
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(allEventsHaveEditIds(data)).toBe(false)
|
||||
})
|
||||
|
||||
it('true when all editables and action params carry editIds', () => {
|
||||
const data = makeData({
|
||||
events: [editableEvent('delay1')] as RecordingData['events'],
|
||||
actionParams: [
|
||||
{
|
||||
selector: 'locator(form)',
|
||||
method: 'click',
|
||||
occurrence: 0,
|
||||
editId: 'click1',
|
||||
params: {},
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(allEventsHaveEditIds(data)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRecordingFresh', () => {
|
||||
it('fresh when hashes match and all editIds are present', () => {
|
||||
const data = makeData({
|
||||
events: [editableEvent('delay1')] as RecordingData['events'],
|
||||
})
|
||||
expect(isRecordingFresh(data, 'hash-a')).toBe(true)
|
||||
})
|
||||
|
||||
it('stale on hash mismatch', () => {
|
||||
expect(isRecordingFresh(makeData(), 'hash-b')).toBe(false)
|
||||
})
|
||||
|
||||
it('stale when either hash is missing', () => {
|
||||
expect(isRecordingFresh(makeData(), undefined)).toBe(false)
|
||||
const noHash = makeData({
|
||||
metadata: { videoName: 'Demo', screenciVersion: '0.0.0' },
|
||||
})
|
||||
expect(isRecordingFresh(noHash, 'hash-a')).toBe(false)
|
||||
})
|
||||
|
||||
it('stale when an editId is missing even with matching hash', () => {
|
||||
const data = makeData({
|
||||
events: [editableEvent(undefined)] as RecordingData['events'],
|
||||
})
|
||||
expect(isRecordingFresh(data, 'hash-a')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readKeptRecordingData', () => {
|
||||
it('prefers data.json over last-data.json', async () => {
|
||||
const files: Record<string, string> = {
|
||||
'/rec/data.json': JSON.stringify(makeData()),
|
||||
[`/rec/${LAST_DATA_FILE}`]: JSON.stringify(
|
||||
makeData({
|
||||
metadata: {
|
||||
videoName: 'Old',
|
||||
screenciVersion: '0.0.0',
|
||||
},
|
||||
})
|
||||
),
|
||||
}
|
||||
const data = await readKeptRecordingData('/rec', async (p) => {
|
||||
const content = files[p]
|
||||
if (content === undefined) throw new Error('ENOENT')
|
||||
return Buffer.from(content)
|
||||
})
|
||||
expect(data?.metadata?.videoName).toBe('Demo')
|
||||
})
|
||||
|
||||
it('falls back to last-data.json', async () => {
|
||||
const files: Record<string, string> = {
|
||||
[`/rec/${LAST_DATA_FILE}`]: JSON.stringify(makeData()),
|
||||
}
|
||||
const data = await readKeptRecordingData('/rec', async (p) => {
|
||||
const content = files[p]
|
||||
if (content === undefined) throw new Error('ENOENT')
|
||||
return Buffer.from(content)
|
||||
})
|
||||
expect(data?.metadata?.videoName).toBe('Demo')
|
||||
})
|
||||
|
||||
it('resolves null when neither file exists', async () => {
|
||||
const data = await readKeptRecordingData('/rec', async () => {
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
expect(data).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createHash } from 'crypto'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
|
||||
import type { RecordingData, RecordingEvent } from './recordingData.js'
|
||||
import type { EditableMeta } from './editableDescriptor.js'
|
||||
|
||||
/**
|
||||
* Freshness check for kept recordings.
|
||||
*
|
||||
* `data.json` survives uploads (only the media files are cleaned up) so the
|
||||
* next `screenci dev` session can decide whether a recording needs to be
|
||||
* re-recorded at all. A recording is fresh when the test source it came from
|
||||
* is byte-identical (same `sourceHash`) AND every editable action in it is
|
||||
* addressable by an `editId`. Anything less means the code changed or some
|
||||
* action still needs an editId stamped, so a (preview) record run is required
|
||||
* before the editor may edit the timeline.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Name of a previous run's kept event data inside a recording directory.
|
||||
* `clearRecordingDirectories` renames `data.json` to this at run start so the
|
||||
* upload phase never mistakes a kept recording for a freshly recorded one.
|
||||
*/
|
||||
export const LAST_DATA_FILE = 'last-data.json'
|
||||
|
||||
/**
|
||||
* Reads the kept recording data of a recording directory: this run's
|
||||
* `data.json` when present, otherwise a previous run's `last-data.json`.
|
||||
* Resolves to null when neither exists or parsing fails.
|
||||
*/
|
||||
export async function readKeptRecordingData(
|
||||
recordingDir: string,
|
||||
readFileFn: ReadFileFn = (p) => readFile(p)
|
||||
): Promise<RecordingData | null> {
|
||||
for (const name of ['data.json', LAST_DATA_FILE]) {
|
||||
try {
|
||||
const raw = await readFileFn(join(recordingDir, name))
|
||||
return JSON.parse(raw.toString()) as RecordingData
|
||||
} catch {
|
||||
// Missing or unparsable: try the next candidate.
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** SHA-256 hex digest of the given source file content. */
|
||||
export function computeSourceHash(content: string | Buffer): string {
|
||||
return createHash('sha256').update(content).digest('hex')
|
||||
}
|
||||
|
||||
export type ReadFileFn = (path: string) => Promise<Buffer>
|
||||
|
||||
/**
|
||||
* Hashes a test source file on disk. Resolves to undefined when the file
|
||||
* cannot be read (missing file: the recording can never be fresh).
|
||||
*/
|
||||
export async function hashSourceFile(
|
||||
filePath: string,
|
||||
readFileFn: ReadFileFn = (p) => readFile(p)
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
return computeSourceHash(await readFileFn(filePath))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function eventEditable(event: RecordingEvent): EditableMeta | undefined {
|
||||
return (event as { editable?: EditableMeta }).editable
|
||||
}
|
||||
|
||||
/**
|
||||
* True when every editable event and every recorded action parameter carries
|
||||
* an editId. Events without editable metadata (sleeps, cue ends, ...) do not
|
||||
* need one.
|
||||
*/
|
||||
export function allEventsHaveEditIds(data: RecordingData): boolean {
|
||||
for (const event of data.events) {
|
||||
const editable = eventEditable(event)
|
||||
if (editable !== undefined && editable.descriptor.editId === undefined) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for (const record of data.actionParams ?? []) {
|
||||
if (record.editId === undefined) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* A kept recording is fresh (recording can be skipped) when its stored source
|
||||
* hash matches the current test file's hash and every editable action already
|
||||
* has an editId. `currentSourceHash` is undefined when the source file could
|
||||
* not be read; that is never fresh.
|
||||
*/
|
||||
export function isRecordingFresh(
|
||||
data: RecordingData,
|
||||
currentSourceHash: string | undefined
|
||||
): boolean {
|
||||
const storedHash = data.metadata?.sourceHash
|
||||
if (storedHash === undefined || currentSourceHash === undefined) return false
|
||||
if (storedHash !== currentSourceHash) return false
|
||||
return allEventsHaveEditIds(data)
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import type { ScreenshotClipRecord } from './clip.js'
|
||||
import type { CueDurationsMap } from './cueDurations.js'
|
||||
import type { ResolvedRedactStyle } from './redactController.js'
|
||||
import type { EditablePosition } from './editableDescriptor.js'
|
||||
import type { OverrideReportBuilder } from './timelineEdits.js'
|
||||
|
||||
export type CurrentZoomViewport = {
|
||||
focusPoint: { x: number; y: number }
|
||||
@@ -158,17 +157,6 @@ export type ScreenCIRuntimeContext = {
|
||||
editable: {
|
||||
seq: number
|
||||
ordinalByIdentity: Map<string, number>
|
||||
/**
|
||||
* Web-editor overrides for the active recording, indexed by stable key.
|
||||
* Null when none were injected (plain `test` runs, no stored edits).
|
||||
*/
|
||||
overridesByKey: Map<string, Record<string, unknown>> | null
|
||||
/**
|
||||
* Report collector for this recording's override applications. Shared
|
||||
* with the recorder so runtime param edits and write-time placed events
|
||||
* land in one report. Null outside instrumented record runs.
|
||||
*/
|
||||
report: OverrideReportBuilder | null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,8 +220,6 @@ export function createScreenCIRuntimeContext(
|
||||
editable: {
|
||||
seq: 0,
|
||||
ordinalByIdentity: new Map<string, number>(),
|
||||
overridesByKey: null,
|
||||
report: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -431,39 +417,6 @@ export function resetEditableRuntimeState(): void {
|
||||
const state = getScreenCIRuntimeContext().editable
|
||||
state.seq = 0
|
||||
state.ordinalByIdentity.clear()
|
||||
state.overridesByKey = null
|
||||
state.report = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the active recording's web-editor overrides (indexed by stable key)
|
||||
* so editable actions can resolve them. Pass null to clear.
|
||||
*/
|
||||
export function setEditableRunOverrides(
|
||||
overridesByKey: Map<string, Record<string, unknown>> | null
|
||||
): void {
|
||||
getScreenCIRuntimeContext().editable.overridesByKey = overridesByKey
|
||||
}
|
||||
|
||||
export function getEditableRunOverrides(): Map<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
> | null {
|
||||
return getScreenCIRuntimeContext().editable.overridesByKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the active recording's override report so runtime edit applications
|
||||
* are collected alongside write-time placed events. Pass null to clear.
|
||||
*/
|
||||
export function setEditableRunReport(
|
||||
report: OverrideReportBuilder | null
|
||||
): void {
|
||||
getScreenCIRuntimeContext().editable.report = report
|
||||
}
|
||||
|
||||
export function getEditableRunReport(): OverrideReportBuilder | null {
|
||||
return getScreenCIRuntimeContext().editable.report
|
||||
}
|
||||
|
||||
export function getRuntimeAutoZoomState(): AutoZoomState {
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
isTimingDebugEnabled,
|
||||
isUploadExistingEnabled,
|
||||
mergeStudioRecordOptions,
|
||||
parseActionOverrides,
|
||||
parseRecordOptions,
|
||||
parseRequestedLanguages,
|
||||
parseValuesOverrides,
|
||||
@@ -307,40 +306,3 @@ describe('mergeStudioRecordOptions', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseActionOverrides', () => {
|
||||
it('returns null when unset or blank', () => {
|
||||
expect(parseActionOverrides({})).toBeNull()
|
||||
expect(parseActionOverrides({ SCREENCI_ACTION_OVERRIDES: ' ' })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for malformed JSON or non-object payloads', () => {
|
||||
expect(
|
||||
parseActionOverrides({ SCREENCI_ACTION_OVERRIDES: 'not json' })
|
||||
).toBeNull()
|
||||
expect(
|
||||
parseActionOverrides({ SCREENCI_ACTION_OVERRIDES: '[1]' })
|
||||
).toBeNull()
|
||||
expect(
|
||||
parseActionOverrides({ SCREENCI_ACTION_OVERRIDES: '"x"' })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('parses per-video override maps and drops non-object entries', () => {
|
||||
const payload = JSON.stringify({
|
||||
'My video': {
|
||||
"getByRole('button')|click|0|move.duration": 250,
|
||||
"getByRole('button')|click|0|position": { x: 1, y: 2 },
|
||||
},
|
||||
broken: 'nope',
|
||||
})
|
||||
expect(
|
||||
parseActionOverrides({ SCREENCI_ACTION_OVERRIDES: payload })
|
||||
).toEqual({
|
||||
'My video': {
|
||||
"getByRole('button')|click|0|move.duration": 250,
|
||||
"getByRole('button')|click|0|position": { x: 1, y: 2 },
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { AspectRatio, FPS, Quality } from './types.js'
|
||||
import type { ActionOverridesByVideo } from './actionParams.js'
|
||||
import { isScreenshotCapture } from './runtimeContext.js'
|
||||
|
||||
export const SCREENCI_RECORDING_ENV = 'SCREENCI_RECORDING'
|
||||
export const SCREENCI_ACTION_OVERRIDES_ENV = 'SCREENCI_ACTION_OVERRIDES'
|
||||
export const SCREENCI_MOCK_RECORD_ENV = 'SCREENCI_MOCK_RECORD'
|
||||
export const SCREENCI_LANGUAGES_ENV = 'SCREENCI_LANGUAGES'
|
||||
export const SCREENCI_VALUES_OVERRIDES_ENV = 'SCREENCI_VALUES_OVERRIDES'
|
||||
@@ -248,41 +246,6 @@ export function parseRecordOptions(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse web-editor action-parameter overrides injected for a re-record. The
|
||||
* payload is a JSON map of
|
||||
* `{ [videoName]: { "<selector>|<method>|<occurrence>|<optionPath>": value } }`,
|
||||
* set by the CLI from the backend before the recording runs (see
|
||||
* `SCREENCI_ACTION_OVERRIDES`). Returns `null` when unset or malformed, so
|
||||
* actions fall back to the code values.
|
||||
*/
|
||||
export function parseActionOverrides(
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): ActionOverridesByVideo | null {
|
||||
const raw = env[SCREENCI_ACTION_OVERRIDES_ENV]
|
||||
if (raw === undefined || raw.trim().length === 0) return null
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
||||
return null
|
||||
|
||||
const result: ActionOverridesByVideo = {}
|
||||
for (const [videoName, overrides] of Object.entries(parsed)) {
|
||||
if (
|
||||
typeof overrides !== 'object' ||
|
||||
overrides === null ||
|
||||
Array.isArray(overrides)
|
||||
)
|
||||
continue
|
||||
result[videoName] = { ...(overrides as Record<string, unknown>) }
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge Studio record-option overrides over the code-declared record options.
|
||||
* Only the Studio-owned fields (aspect ratio, quality, fps, actual narration
|
||||
|
||||
+4
-4
@@ -59,7 +59,7 @@ import {
|
||||
buildValues,
|
||||
type Values,
|
||||
} from './localizeRuntime.js'
|
||||
import { parseActionOverrides, parseValuesOverrides } from './runtimeMode.js'
|
||||
import { parseValuesOverrides } from './runtimeMode.js'
|
||||
import { ActionParamCollector } from './actionParams.js'
|
||||
import {
|
||||
combineRecordOptionsLayers,
|
||||
@@ -303,9 +303,9 @@ const _screenshotBase = base.extend<
|
||||
renderOptions: true,
|
||||
recordOptions: true,
|
||||
},
|
||||
// Action-parameter provenance for this capture, with the web editor's
|
||||
// per-action overrides (fetched by the CLI, injected via env) applied.
|
||||
new ActionParamCollector(parseActionOverrides()?.[videoName] ?? {})
|
||||
// Action-parameter provenance for this capture (values and their
|
||||
// explicit/default provenance, straight from code).
|
||||
new ActionParamCollector()
|
||||
)
|
||||
recorder.setActiveLanguage(_screenciLanguage ?? null)
|
||||
// Declared `values` fields (and the active language's seeds) emitted once at
|
||||
|
||||
+1
-347
@@ -1,277 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
OverrideReportBuilder,
|
||||
SCREENCI_TIMELINE_EDITS_ENV,
|
||||
cueIdFor,
|
||||
overlayDeclIdFor,
|
||||
overlayIdFor,
|
||||
parseTimelineEdits,
|
||||
resolveTimelineEditsForVideo,
|
||||
splitEdits,
|
||||
type EditRecord,
|
||||
} from './timelineEdits.js'
|
||||
|
||||
const env = (value: string): NodeJS.ProcessEnv => ({
|
||||
[SCREENCI_TIMELINE_EDITS_ENV]: value,
|
||||
})
|
||||
|
||||
describe('parseTimelineEdits', () => {
|
||||
it('returns null when unset or malformed', () => {
|
||||
expect(parseTimelineEdits({})).toBeNull()
|
||||
expect(parseTimelineEdits(env('not json'))).toBeNull()
|
||||
expect(parseTimelineEdits(env('42'))).toBeNull()
|
||||
})
|
||||
|
||||
it('parses every valid record kind', () => {
|
||||
const doc = {
|
||||
demo: {
|
||||
version: 3,
|
||||
edits: [
|
||||
{
|
||||
type: 'paramEdit',
|
||||
id: 'p1',
|
||||
target: { key: 'delay||intro|0' },
|
||||
fields: { durationMs: 800 },
|
||||
},
|
||||
{
|
||||
type: 'renameEdit',
|
||||
id: 'r1',
|
||||
target: { editId: 'click1' },
|
||||
newEditId: 'save',
|
||||
},
|
||||
{
|
||||
type: 'mediaEdit',
|
||||
id: 'm1',
|
||||
kind: 'narrationCue',
|
||||
afterEditId: 'click1',
|
||||
blocking: true,
|
||||
sleepBeforeMs: 200,
|
||||
props: { name: 'intro' },
|
||||
},
|
||||
{
|
||||
type: 'zoomEdit',
|
||||
id: 'z1',
|
||||
fromEditId: 'click1',
|
||||
untilEditId: 'fill1',
|
||||
leadInMs: 400,
|
||||
holdMs: 600,
|
||||
},
|
||||
{
|
||||
type: 'gapSpanEdit',
|
||||
id: 'g1',
|
||||
kind: 'hide',
|
||||
fromEditId: 'click1',
|
||||
untilEditId: 'fill1',
|
||||
},
|
||||
{
|
||||
type: 'gapPointEdit',
|
||||
id: 'gp1',
|
||||
kind: 'background',
|
||||
afterEditId: 'click1',
|
||||
props: { backgroundCss: '#101014' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const parsed = parseTimelineEdits(env(JSON.stringify(doc)))
|
||||
expect(parsed?.demo.edits).toHaveLength(6)
|
||||
expect(parsed?.demo.invalid).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps invalid records as reported problems instead of dropping them', () => {
|
||||
const doc = {
|
||||
demo: {
|
||||
version: 3,
|
||||
edits: [
|
||||
{ type: 'mediaEdit', id: 'bad1', kind: 'narrationCue' }, // no afterEditId/blocking
|
||||
{ type: 'paramEdit', id: 'bad2' },
|
||||
{ type: 'zoomEdit', id: 'bad3', fromEditId: 'a' }, // no untilEditId
|
||||
{
|
||||
type: 'gapSpanEdit',
|
||||
id: 'bad4',
|
||||
kind: 'nope',
|
||||
fromEditId: 'a',
|
||||
untilEditId: 'b',
|
||||
},
|
||||
{ type: 'mystery', id: 'bad5' },
|
||||
{
|
||||
type: 'gapPointEdit',
|
||||
id: 'ok',
|
||||
kind: 'recording',
|
||||
afterEditId: 'click1',
|
||||
props: { visible: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const parsed = parseTimelineEdits(env(JSON.stringify(doc)))
|
||||
expect(parsed?.demo.edits.map((edit) => edit.id)).toEqual(['ok'])
|
||||
expect(parsed?.demo.invalid.map((entry) => entry.id)).toEqual([
|
||||
'bad1',
|
||||
'bad2',
|
||||
'bad3',
|
||||
'bad4',
|
||||
'bad5',
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves per-video edits', () => {
|
||||
const doc: Record<string, unknown> = {
|
||||
other: { version: 3, edits: [] },
|
||||
}
|
||||
expect(
|
||||
resolveTimelineEditsForVideo('missing', env(JSON.stringify(doc)))
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseTimelineEdits: delayMs validation', () => {
|
||||
const env = (value: string) =>
|
||||
({ SCREENCI_TIMELINE_EDITS: value }) as NodeJS.ProcessEnv
|
||||
|
||||
const docWith = (edit: Record<string, unknown>) =>
|
||||
JSON.stringify({ demo: { version: 3, edits: [edit] } })
|
||||
|
||||
const point = {
|
||||
type: 'gapPointEdit',
|
||||
id: 'gp1',
|
||||
kind: 'background',
|
||||
afterEditId: 'click1',
|
||||
props: { backgroundCss: '#101014' },
|
||||
}
|
||||
|
||||
it('accepts a positive integer delayMs on point, media, and span edits', () => {
|
||||
const doc = JSON.stringify({
|
||||
demo: {
|
||||
version: 3,
|
||||
edits: [
|
||||
{ ...point, delayMs: 500 },
|
||||
{
|
||||
type: 'mediaEdit',
|
||||
id: 'm1',
|
||||
kind: 'overlay',
|
||||
afterEditId: 'click1',
|
||||
blocking: false,
|
||||
delayMs: 250,
|
||||
props: { name: 'logo' },
|
||||
},
|
||||
{
|
||||
type: 'gapSpanEdit',
|
||||
id: 'g1',
|
||||
kind: 'hide',
|
||||
fromEditId: 'a',
|
||||
untilEditId: 'b',
|
||||
delayMs: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const parsed = parseTimelineEdits(env(doc))
|
||||
expect(parsed?.demo.edits).toHaveLength(3)
|
||||
expect(parsed?.demo.invalid).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects zero, negative, and non-integer delayMs', () => {
|
||||
for (const delayMs of [0, -100, 1.5, 'x']) {
|
||||
const parsed = parseTimelineEdits(env(docWith({ ...point, delayMs })))
|
||||
expect(parsed?.demo.invalid).toEqual([
|
||||
{ id: 'gp1', reason: 'invalid delayMs' },
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects delayMs combined with a positive sleep field', () => {
|
||||
const parsed = parseTimelineEdits(
|
||||
env(docWith({ ...point, delayMs: 500, sleepBeforeMs: 300 }))
|
||||
)
|
||||
expect(parsed?.demo.invalid).toEqual([
|
||||
{
|
||||
id: 'gp1',
|
||||
reason: 'delayMs cannot combine with a positive sleepBeforeMs',
|
||||
},
|
||||
])
|
||||
|
||||
const span = parseTimelineEdits(
|
||||
env(
|
||||
docWith({
|
||||
type: 'gapSpanEdit',
|
||||
id: 'g1',
|
||||
kind: 'hide',
|
||||
fromEditId: 'a',
|
||||
untilEditId: 'b',
|
||||
delayMs: 400,
|
||||
fromSleepMs: 200,
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(span?.demo.invalid).toEqual([
|
||||
{
|
||||
id: 'g1',
|
||||
reason: 'delayMs cannot combine with a positive fromSleepMs',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('allows delayMs next to a zero sleep field', () => {
|
||||
const parsed = parseTimelineEdits(
|
||||
env(docWith({ ...point, delayMs: 500, sleepBeforeMs: 0 }))
|
||||
)
|
||||
expect(parsed?.demo.invalid).toHaveLength(0)
|
||||
expect(parsed?.demo.edits).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects delayMs on a blocking media edit', () => {
|
||||
const parsed = parseTimelineEdits(
|
||||
env(
|
||||
docWith({
|
||||
type: 'mediaEdit',
|
||||
id: 'm1',
|
||||
kind: 'narrationCue',
|
||||
afterEditId: 'click1',
|
||||
blocking: true,
|
||||
delayMs: 250,
|
||||
props: { name: 'intro' },
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(parsed?.demo.invalid).toEqual([
|
||||
{ id: 'm1', reason: 'delayMs requires blocking: false' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitEdits', () => {
|
||||
it('splits param edits from codify records and drops disabled/rename', () => {
|
||||
const edits: EditRecord[] = [
|
||||
{ type: 'paramEdit', id: 'p1', target: { key: 'k' }, fields: {} },
|
||||
{
|
||||
type: 'renameEdit',
|
||||
id: 'r1',
|
||||
target: { editId: 'a' },
|
||||
newEditId: 'b',
|
||||
},
|
||||
{
|
||||
type: 'mediaEdit',
|
||||
id: 'm1',
|
||||
kind: 'overlay',
|
||||
afterEditId: 'click1',
|
||||
blocking: false,
|
||||
props: { name: 'logo' },
|
||||
},
|
||||
{
|
||||
type: 'zoomEdit',
|
||||
id: 'z1',
|
||||
fromEditId: 'a',
|
||||
untilEditId: 'b',
|
||||
disabled: true,
|
||||
},
|
||||
]
|
||||
const split = splitEdits(edits)
|
||||
expect(split.paramEdits.map((edit) => edit.id)).toEqual(['p1'])
|
||||
expect(split.codifyEdits.map((edit) => edit.id)).toEqual(['m1'])
|
||||
})
|
||||
})
|
||||
import { cueIdFor, overlayDeclIdFor, overlayIdFor } from './timelineEdits.js'
|
||||
|
||||
describe('cueIdFor / overlayIdFor', () => {
|
||||
it('produces stable name-ordinal ids', () => {
|
||||
@@ -280,78 +9,3 @@ describe('cueIdFor / overlayIdFor', () => {
|
||||
expect(overlayDeclIdFor('logo')).toBe('overlaydecl-logo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('overlayDeclEdit records', () => {
|
||||
it('parses a valid overlayDeclEdit', () => {
|
||||
const doc = {
|
||||
demo: {
|
||||
version: 3,
|
||||
edits: [
|
||||
{
|
||||
type: 'overlayDeclEdit',
|
||||
id: 'overlaydecl-logo',
|
||||
overlayName: 'logo',
|
||||
props: { x: 96, width: 240 },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const parsed = parseTimelineEdits(env(JSON.stringify(doc)))
|
||||
expect(parsed?.demo?.edits).toHaveLength(1)
|
||||
expect(parsed?.demo?.invalid).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects records missing overlayName or props', () => {
|
||||
const doc = {
|
||||
demo: {
|
||||
version: 3,
|
||||
edits: [
|
||||
{ type: 'overlayDeclEdit', id: 'd1', props: { x: 1 } },
|
||||
{ type: 'overlayDeclEdit', id: 'd2', overlayName: 'logo' },
|
||||
],
|
||||
},
|
||||
}
|
||||
const parsed = parseTimelineEdits(env(JSON.stringify(doc)))
|
||||
expect(parsed?.demo?.edits).toEqual([])
|
||||
expect(parsed?.demo?.invalid.map((entry) => entry.id)).toEqual(['d1', 'd2'])
|
||||
})
|
||||
|
||||
it('is ignored by splitEdits at record time', () => {
|
||||
const edits: EditRecord[] = [
|
||||
{
|
||||
type: 'overlayDeclEdit',
|
||||
id: 'overlaydecl-logo',
|
||||
overlayName: 'logo',
|
||||
props: { margin: 8 },
|
||||
},
|
||||
]
|
||||
const split = splitEdits(edits)
|
||||
expect(split.paramEdits).toEqual([])
|
||||
expect(split.codifyEdits).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('OverrideReportBuilder', () => {
|
||||
it('always logs non-applied outcomes and a summary', () => {
|
||||
const lines: string[] = []
|
||||
const report = new OverrideReportBuilder((line) => lines.push(line))
|
||||
report.add({
|
||||
editId: 'e1',
|
||||
channel: 'paramEdit',
|
||||
status: 'applied',
|
||||
resolvedStartMs: 100,
|
||||
})
|
||||
report.add({
|
||||
editId: 'e2',
|
||||
channel: 'codifyEdit',
|
||||
status: 'skipped',
|
||||
reason: 'invalidRecord:missing id',
|
||||
})
|
||||
report.logSummary('demo')
|
||||
expect(lines.some((line) => line.includes('SKIPPED'))).toBe(true)
|
||||
expect(lines.some((line) => line.includes('1 applied, 1 skipped'))).toBe(
|
||||
true
|
||||
)
|
||||
expect(report.items()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
+12
-376
@@ -1,23 +1,20 @@
|
||||
/**
|
||||
* Unified timeline edits: the single wire format for web-editor overrides.
|
||||
* Unified timeline edits: the typed record shapes of web-editor edits.
|
||||
*
|
||||
* A `TimelineEditsDoc` carries every edit the web editor stored for a video as
|
||||
* typed records keyed by the stable code identity of an action (its `editId`
|
||||
* slug). There are no anchors and no offsets: position is "where the call sits
|
||||
* in code call-order," and timing gaps are plain `waitForTimeout` sleeps.
|
||||
* A `TimelineEditsDoc` carries every edit the web editor produced for a video
|
||||
* as typed records keyed by the stable code identity of an action (its
|
||||
* `editId` slug). There are no anchors and no offsets: position is "where the
|
||||
* call sits in code call-order," and timing gaps are plain `waitForTimeout`
|
||||
* sleeps.
|
||||
*
|
||||
* Code is the single source of truth: edits arrive over the dev channel as
|
||||
* codegen requests (`screenci dev`, see applyCodegen.ts) and are written
|
||||
* straight into the .screenci.ts sources. Nothing is applied at record time;
|
||||
* a recording always runs purely from code values.
|
||||
*
|
||||
* Two record shapes apply at record time:
|
||||
* - `paramEdit`: changes parameter fields of a recorded action (durations,
|
||||
* sleeps, typing delay). It targets an action by its stable editable key.
|
||||
* - `renameEdit`: renames an action's `editId` slug (codified by `screenci
|
||||
* sync`; nothing to apply at record time).
|
||||
*
|
||||
* The remaining records are codify-only: they are never materialized into the
|
||||
* recorded event list. `screenci sync` writes them into the .screenci.ts
|
||||
* sources as real calls (a narration cue, an `autoZoom(...)` bracket, a
|
||||
* `hide(...)` span, a `moveNarration(...)` point, etc.), and normal recording
|
||||
* then emits the corresponding events. Each codify record locates its call
|
||||
* site by an `editId`:
|
||||
* - `renameEdit`: renames an action's `editId` slug.
|
||||
* - `mediaEdit`: a narration cue / overlay / audio start placed in the gap
|
||||
* after an action. `blocking:true` awaits it (backbone, advances the
|
||||
* timeline); `blocking:false` fires and forgets (background). End is never
|
||||
@@ -28,15 +25,7 @@
|
||||
* in gaps (after `fromEditId` and after `untilEditId`).
|
||||
* - `gapPointEdit`: an instant `moveNarration`/`resizeRecording`/`setBackground`
|
||||
* point in the gap after an action.
|
||||
*
|
||||
* Before a record run the CLI fetches the doc and injects it via
|
||||
* `SCREENCI_TIMELINE_EDITS` (a JSON map of video name to doc). Only param edits
|
||||
* apply at runtime (they change real waits). Every param edit produces an
|
||||
* `OverrideReportItem` so nothing is ever silently skipped.
|
||||
*/
|
||||
import { isOverrideDebugEnabled } from './debugFlags.js'
|
||||
|
||||
export const SCREENCI_TIMELINE_EDITS_ENV = 'SCREENCI_TIMELINE_EDITS'
|
||||
|
||||
export const TIMELINE_EDITS_VERSION = 3
|
||||
|
||||
@@ -188,359 +177,6 @@ export type TimelineEditsDoc = {
|
||||
edits: EditRecord[]
|
||||
}
|
||||
|
||||
// ─── Override report ─────────────────────────────────────────────────────────
|
||||
|
||||
export type OverrideReportStatus =
|
||||
| 'applied'
|
||||
| 'fallback'
|
||||
| 'shadowed-code'
|
||||
| 'skipped'
|
||||
|
||||
export type OverrideReportChannel =
|
||||
| 'paramEdit'
|
||||
| 'codifyEdit'
|
||||
| 'legacyEditable'
|
||||
| 'legacyAuthored'
|
||||
| 'fetch'
|
||||
|
||||
export type OverrideReportItem = {
|
||||
editId: string
|
||||
channel: OverrideReportChannel
|
||||
status: OverrideReportStatus
|
||||
/** Codify-edit kind or paramEdit target key, for readable logs. */
|
||||
subject?: string
|
||||
reason?: string
|
||||
resolvedStartMs?: number
|
||||
resolvedEndMs?: number
|
||||
appliedValues?: Record<string, unknown>
|
||||
codeValues?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects one item per edit application attempt and turns them into
|
||||
* readable log lines. Injected wherever overrides are applied so the whole
|
||||
* run produces a single report, embedded in data.json and uploaded.
|
||||
*/
|
||||
export class OverrideReportBuilder {
|
||||
private readonly reportItems: OverrideReportItem[] = []
|
||||
|
||||
constructor(
|
||||
private readonly log: (message: string) => void = (message) =>
|
||||
console.warn(message)
|
||||
) {}
|
||||
|
||||
add(item: OverrideReportItem): void {
|
||||
this.reportItems.push(item)
|
||||
// Skips, fallbacks and shadowed code values always log; applied detail
|
||||
// only with override debugging enabled.
|
||||
if (item.status === 'applied' && !isOverrideDebugEnabled()) return
|
||||
this.log(formatReportItem(item))
|
||||
}
|
||||
|
||||
items(): OverrideReportItem[] {
|
||||
return [...this.reportItems]
|
||||
}
|
||||
|
||||
/** Logs the end-of-video summary block (counts per status). */
|
||||
logSummary(videoName: string): void {
|
||||
if (this.reportItems.length === 0) return
|
||||
const counts = new Map<OverrideReportStatus, number>()
|
||||
for (const item of this.reportItems) {
|
||||
counts.set(item.status, (counts.get(item.status) ?? 0) + 1)
|
||||
}
|
||||
const parts = [...counts.entries()].map(
|
||||
([status, count]) => `${count} ${status}`
|
||||
)
|
||||
this.log(`[screenci overrides] ${videoName}: ${parts.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatReportItem(item: OverrideReportItem): string {
|
||||
const status =
|
||||
item.status === 'applied' ? 'applied' : item.status.toUpperCase()
|
||||
const subject = item.subject !== undefined ? ` ${item.subject}` : ''
|
||||
const range =
|
||||
item.resolvedStartMs !== undefined
|
||||
? item.resolvedEndMs !== undefined &&
|
||||
item.resolvedEndMs !== item.resolvedStartMs
|
||||
? ` -> ${Math.round(item.resolvedStartMs)}..${Math.round(item.resolvedEndMs)}ms`
|
||||
: ` -> ${Math.round(item.resolvedStartMs)}ms`
|
||||
: ''
|
||||
const reason = item.reason !== undefined ? ` reason=${item.reason}` : ''
|
||||
return `[screenci overrides] ${status} ${item.channel}${subject} ${item.editId}${range}${reason}`
|
||||
}
|
||||
|
||||
// ─── Parsing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type InvalidEdit = { id: string; reason: string }
|
||||
|
||||
export type ParsedVideoEdits = {
|
||||
edits: EditRecord[]
|
||||
/** Records that failed validation: reported, never silently dropped. */
|
||||
invalid: InvalidEdit[]
|
||||
}
|
||||
|
||||
export type TimelineEditsByVideo = Record<string, ParsedVideoEdits>
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0
|
||||
}
|
||||
|
||||
function optionalNonNegativeMs(value: unknown): boolean {
|
||||
return (
|
||||
value === undefined ||
|
||||
(typeof value === 'number' && Number.isFinite(value) && value >= 0)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an optional `delayMs` against its sibling sleep field: a delay
|
||||
* must be a positive integer and cannot combine with a positive sleep (the
|
||||
* two encode contradictory placements: before vs after the anchor).
|
||||
*/
|
||||
function delayMsProblem(
|
||||
delayMs: unknown,
|
||||
sleepMs: unknown,
|
||||
sleepField: string
|
||||
): string | null {
|
||||
if (delayMs === undefined) return null
|
||||
if (
|
||||
typeof delayMs !== 'number' ||
|
||||
!Number.isInteger(delayMs) ||
|
||||
delayMs <= 0
|
||||
) {
|
||||
return 'invalid delayMs'
|
||||
}
|
||||
if (typeof sleepMs === 'number' && sleepMs > 0) {
|
||||
return `delayMs cannot combine with a positive ${sleepField}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function optionalProps(value: unknown): boolean {
|
||||
return value === undefined || (typeof value === 'object' && value !== null)
|
||||
}
|
||||
|
||||
/** Why a record is invalid, or null when it is a valid {@link EditRecord}. */
|
||||
function editRecordProblem(value: unknown): string | null {
|
||||
if (typeof value !== 'object' || value === null) return 'not an object'
|
||||
const record = value as Record<string, unknown>
|
||||
if (!isNonEmptyString(record.id)) return 'missing id'
|
||||
switch (record.type) {
|
||||
case 'paramEdit': {
|
||||
const target = record.target as Record<string, unknown> | null
|
||||
if (
|
||||
typeof target !== 'object' ||
|
||||
target === null ||
|
||||
!isNonEmptyString(target.key)
|
||||
) {
|
||||
return 'paramEdit missing target.key'
|
||||
}
|
||||
if (typeof record.fields !== 'object' || record.fields === null) {
|
||||
return 'paramEdit missing fields'
|
||||
}
|
||||
return null
|
||||
}
|
||||
case 'renameEdit': {
|
||||
const target = record.target as Record<string, unknown> | null
|
||||
if (
|
||||
typeof target !== 'object' ||
|
||||
target === null ||
|
||||
!isNonEmptyString(target.editId)
|
||||
) {
|
||||
return 'renameEdit missing target.editId'
|
||||
}
|
||||
if (!isNonEmptyString(record.newEditId)) {
|
||||
return 'renameEdit missing newEditId'
|
||||
}
|
||||
return null
|
||||
}
|
||||
case 'mediaEdit': {
|
||||
if (
|
||||
!(MEDIA_EDIT_KINDS as readonly string[]).includes(record.kind as string)
|
||||
) {
|
||||
return `unknown mediaEdit kind '${String(record.kind)}'`
|
||||
}
|
||||
if (!isNonEmptyString(record.afterEditId))
|
||||
return 'mediaEdit missing afterEditId'
|
||||
if (typeof record.blocking !== 'boolean')
|
||||
return 'mediaEdit missing blocking'
|
||||
if (!optionalNonNegativeMs(record.sleepBeforeMs))
|
||||
return 'invalid sleepBeforeMs'
|
||||
{
|
||||
const problem = delayMsProblem(
|
||||
record.delayMs,
|
||||
record.sleepBeforeMs,
|
||||
'sleepBeforeMs'
|
||||
)
|
||||
if (problem !== null) return problem
|
||||
}
|
||||
if (record.delayMs !== undefined && record.blocking === true) {
|
||||
return 'delayMs requires blocking: false'
|
||||
}
|
||||
if (!optionalProps(record.props)) return 'invalid props'
|
||||
return null
|
||||
}
|
||||
case 'zoomEdit': {
|
||||
if (!isNonEmptyString(record.fromEditId))
|
||||
return 'zoomEdit missing fromEditId'
|
||||
if (!isNonEmptyString(record.untilEditId))
|
||||
return 'zoomEdit missing untilEditId'
|
||||
if (!optionalNonNegativeMs(record.leadInMs)) return 'invalid leadInMs'
|
||||
if (!optionalNonNegativeMs(record.holdMs)) return 'invalid holdMs'
|
||||
if (!optionalProps(record.props)) return 'invalid props'
|
||||
return null
|
||||
}
|
||||
case 'gapSpanEdit': {
|
||||
if (
|
||||
!(GAP_SPAN_KINDS as readonly string[]).includes(record.kind as string)
|
||||
) {
|
||||
return `unknown gapSpanEdit kind '${String(record.kind)}'`
|
||||
}
|
||||
if (!isNonEmptyString(record.fromEditId))
|
||||
return 'gapSpanEdit missing fromEditId'
|
||||
if (!isNonEmptyString(record.untilEditId))
|
||||
return 'gapSpanEdit missing untilEditId'
|
||||
if (!optionalNonNegativeMs(record.fromSleepMs))
|
||||
return 'invalid fromSleepMs'
|
||||
if (!optionalNonNegativeMs(record.untilSleepMs))
|
||||
return 'invalid untilSleepMs'
|
||||
{
|
||||
const problem = delayMsProblem(
|
||||
record.delayMs,
|
||||
record.fromSleepMs,
|
||||
'fromSleepMs'
|
||||
)
|
||||
if (problem !== null) return problem
|
||||
}
|
||||
if (!optionalProps(record.props)) return 'invalid props'
|
||||
return null
|
||||
}
|
||||
case 'gapPointEdit': {
|
||||
if (
|
||||
!(GAP_POINT_KINDS as readonly string[]).includes(record.kind as string)
|
||||
) {
|
||||
return `unknown gapPointEdit kind '${String(record.kind)}'`
|
||||
}
|
||||
if (!isNonEmptyString(record.afterEditId))
|
||||
return 'gapPointEdit missing afterEditId'
|
||||
if (!optionalNonNegativeMs(record.sleepBeforeMs))
|
||||
return 'invalid sleepBeforeMs'
|
||||
{
|
||||
const problem = delayMsProblem(
|
||||
record.delayMs,
|
||||
record.sleepBeforeMs,
|
||||
'sleepBeforeMs'
|
||||
)
|
||||
if (problem !== null) return problem
|
||||
}
|
||||
if (!optionalProps(record.props)) return 'invalid props'
|
||||
return null
|
||||
}
|
||||
case 'overlayDeclEdit': {
|
||||
if (!isNonEmptyString(record.overlayName)) {
|
||||
return 'overlayDeclEdit missing overlayName'
|
||||
}
|
||||
if (typeof record.props !== 'object' || record.props === null) {
|
||||
return 'overlayDeclEdit missing props'
|
||||
}
|
||||
return null
|
||||
}
|
||||
default:
|
||||
return `unknown record type '${String(record.type)}'`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the injected timeline-edits map. Returns `null` when the env var is
|
||||
* unset or unreadable; per-video invalid records are kept in `invalid` so the
|
||||
* caller can report them instead of losing them silently.
|
||||
*/
|
||||
export function parseTimelineEdits(
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): TimelineEditsByVideo | null {
|
||||
const raw = env[SCREENCI_TIMELINE_EDITS_ENV]
|
||||
if (raw === undefined || raw.trim().length === 0) return null
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) return null
|
||||
|
||||
const result: TimelineEditsByVideo = {}
|
||||
for (const [videoName, doc] of Object.entries(parsed)) {
|
||||
if (typeof doc !== 'object' || doc === null) continue
|
||||
const { edits } = doc as Record<string, unknown>
|
||||
if (!Array.isArray(edits)) continue
|
||||
const valid: EditRecord[] = []
|
||||
const invalid: InvalidEdit[] = []
|
||||
for (const [index, record] of edits.entries()) {
|
||||
const problem = editRecordProblem(record)
|
||||
if (problem === null) {
|
||||
valid.push(record as EditRecord)
|
||||
} else {
|
||||
const id =
|
||||
typeof (record as Record<string, unknown>)?.id === 'string'
|
||||
? ((record as Record<string, unknown>).id as string)
|
||||
: `#${index}`
|
||||
invalid.push({ id, reason: problem })
|
||||
}
|
||||
}
|
||||
if (valid.length > 0 || invalid.length > 0) {
|
||||
result[videoName] = { edits: valid, invalid }
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : null
|
||||
}
|
||||
|
||||
/** The parsed edits for one video from the injected env, or null. */
|
||||
export function resolveTimelineEditsForVideo(
|
||||
videoName: string,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): ParsedVideoEdits | null {
|
||||
return parseTimelineEdits(env)?.[videoName] ?? null
|
||||
}
|
||||
|
||||
export type SplitEdits = {
|
||||
/** Param edits: applied at record time (they change real waits). */
|
||||
paramEdits: ParamEdit[]
|
||||
/** Codify-only records: never materialized at record time. */
|
||||
codifyEdits: CodifyEdit[]
|
||||
}
|
||||
|
||||
export function splitEdits(edits: readonly EditRecord[]): SplitEdits {
|
||||
const paramEdits: ParamEdit[] = []
|
||||
const codifyEdits: CodifyEdit[] = []
|
||||
for (const edit of edits) {
|
||||
switch (edit.type) {
|
||||
case 'paramEdit':
|
||||
paramEdits.push(edit)
|
||||
break
|
||||
case 'renameEdit':
|
||||
// Renames affect code identity only; nothing to apply at record time
|
||||
// (the recorded slug keeps matching until the rename is codified).
|
||||
break
|
||||
case 'overlayDeclEdit':
|
||||
// Declaration placement edits are codified by `screenci sync`; at
|
||||
// record time the studio draft already carries the placement override.
|
||||
break
|
||||
case 'mediaEdit':
|
||||
case 'zoomEdit':
|
||||
case 'gapSpanEdit':
|
||||
case 'gapPointEdit':
|
||||
if (edit.disabled !== true) codifyEdits.push(edit)
|
||||
break
|
||||
default: {
|
||||
const exhaustive: never = edit
|
||||
void exhaustive
|
||||
}
|
||||
}
|
||||
}
|
||||
return { paramEdits, codifyEdits }
|
||||
}
|
||||
|
||||
// ─── Stable ids for cues and overlays ────────────────────────────────────────
|
||||
|
||||
/** Stable id of the nth (0-based) narration cue with this name. */
|
||||
|
||||
+3
-24
@@ -87,8 +87,6 @@ import {
|
||||
isCaptureAudioEnabled,
|
||||
resolveCaptureAudioGain,
|
||||
} from './browserLaunchOptions.js'
|
||||
import { resolveRuntimeOverridesForVideo } from './editableRuntime.js'
|
||||
import { OverrideReportBuilder } from './timelineEdits.js'
|
||||
import {
|
||||
createScreenCIRuntimeContext,
|
||||
runWithScreenCIRuntimeContext,
|
||||
@@ -107,7 +105,6 @@ import {
|
||||
resolveRecordingTimingDuration,
|
||||
parseValuesOverrides,
|
||||
parseRecordOptions,
|
||||
parseActionOverrides,
|
||||
mergeStudioRecordOptions,
|
||||
} from './runtimeMode.js'
|
||||
import { ActionParamCollector } from './actionParams.js'
|
||||
@@ -912,9 +909,9 @@ const _videoBase = base.extend<
|
||||
// knows this video may have languages added/rendered from Studio.
|
||||
languages: _screenciRecordingLocalize?.studioOwned ?? false,
|
||||
},
|
||||
// Action-parameter provenance for this video, with the web editor's
|
||||
// per-action overrides (fetched by the CLI, injected via env) applied.
|
||||
new ActionParamCollector(parseActionOverrides()?.[videoName] ?? {})
|
||||
// Action-parameter provenance for this video (values and their
|
||||
// explicit/default provenance, straight from code).
|
||||
new ActionParamCollector()
|
||||
)
|
||||
// Declared `values` fields (and the active language's seeds) emitted once at
|
||||
// recording start so the backend/Studio learn them.
|
||||
@@ -952,14 +949,6 @@ const _videoBase = base.extend<
|
||||
renderOptions: renderOptionsObj,
|
||||
activeLanguage: _screenciLanguage ?? null,
|
||||
})
|
||||
// Web-editor timing overrides for this video (injected by the CLI for
|
||||
// record and mock-record runs; absent for plain test runs). One report
|
||||
// collects runtime param edits and write-time placed events.
|
||||
const overrideReport = new OverrideReportBuilder()
|
||||
recorder.setOverrideReport(overrideReport)
|
||||
runtimeContext.editable.report = overrideReport
|
||||
runtimeContext.editable.overridesByKey =
|
||||
resolveRuntimeOverridesForVideo(videoName)
|
||||
bindStillCaptureToPage(page)
|
||||
await setupMouseTracking(page, recorder)
|
||||
if (resolveDisableAnimations(recordOptions.disableAnimations, 'video')) {
|
||||
@@ -1020,16 +1009,6 @@ const _videoBase = base.extend<
|
||||
renderOptions: renderOptionsObj,
|
||||
activeLanguage: _screenciLanguage ?? null,
|
||||
})
|
||||
// Web-editor timing overrides for this video, fetched by the CLI before
|
||||
// the run and injected via SCREENCI_TIMELINE_EDITS. One report collects
|
||||
// runtime param edits and write-time placed events, embedded into
|
||||
// data.json.
|
||||
const overrideReport = new OverrideReportBuilder()
|
||||
recorder.setOverrideReport(overrideReport)
|
||||
runtimeContext.editable.report = overrideReport
|
||||
runtimeContext.editable.overridesByKey =
|
||||
resolveRuntimeOverridesForVideo(videoName)
|
||||
|
||||
await setupMouseTracking(page, recorder)
|
||||
if (resolveDisableAnimations(recordOptions.disableAnimations, 'video')) {
|
||||
await installAnimationDisabling(page)
|
||||
|
||||
Reference in New Issue
Block a user