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:
Olli Paloviita
2026-07-11 21:52:29 +03:00
parent 409c0d8080
commit 04393fa30e
36 changed files with 1984 additions and 4680 deletions
+291 -10
View File
@@ -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
View File
@@ -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 () => {
-421
View File
@@ -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({})
})
})
+281 -970
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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()`
-58
View File
@@ -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 },
})
})
})
-80
View File
@@ -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 -87
View File
@@ -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
View File
@@ -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({
-165
View File
@@ -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)
})
})
-187
View File
@@ -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
}
-110
View File
@@ -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')
})
})
-210
View File
@@ -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
}
+75
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(() => {})
}
}
+185
View File
@@ -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')
)
})
})
+201
View File
@@ -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
View File
@@ -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
View File
@@ -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 }
}
+1 -150
View File
@@ -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
View File
@@ -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
View File
@@ -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 }),
+6
View File
@@ -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). */
+182
View File
@@ -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()
})
})
+105
View File
@@ -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)
}
-47
View File
@@ -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 {
-38
View File
@@ -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 },
},
})
})
})
-37
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)