diff --git a/cli.dev.spec.ts b/cli.dev.spec.ts index 3c90147..994b4dc 100644 --- a/cli.dev.spec.ts +++ b/cli.dev.spec.ts @@ -507,6 +507,169 @@ describe('runDevListenLoop', () => { runDevListenLoop(config, deps, 'lst_1', controller) ).rejects.toBeInstanceOf(DevAuthError) }) + + it('runs a local record request without touching the trigger channel', async () => { + const controller = { stopped: false } + const runLocalRecord = vi.fn(async () => {}) + let request: { videoNames: string[] } | null = { + videoNames: ['Intro video'], + } + const takeLocalRequest = vi.fn(() => { + const taken = request + request = null + return taken + }) + const deps = makeDeps({ runLocalRecord, takeLocalRequest }) + deps.fetchMock.mockImplementation(async (url: string) => { + if (url.endsWith('/cli/dev/poll')) { + if (runLocalRecord.mock.calls.length > 0) controller.stopped = true + return jsonResponse({}) + } + return jsonResponse({ ok: true }) + }) + + await runDevListenLoop(config, deps, 'lst_1', controller) + + expect(runLocalRecord).toHaveBeenCalledWith( + ['Intro video'], + expect.any(AbortSignal) + ) + const triggerReports = deps.fetchMock.mock.calls.filter(([url]) => + (url as string).endsWith('/cli/dev/report') + ) + expect(triggerReports).toHaveLength(0) + }) + + it('merges queued local requests into one union re-record', async () => { + const controller = { stopped: false } + let clock = 0 + const recorded: string[][] = [] + let releaseFirst = () => {} + const runLocalRecord = vi.fn((videoNames: string[]) => { + recorded.push(videoNames) + if (recorded.length === 1) { + return new Promise((resolve) => { + releaseFirst = resolve + }) + } + return Promise.resolve() + }) + const requests: Array<{ videoNames: string[] }> = [ + { videoNames: ['A'] }, + { videoNames: ['B'] }, + { videoNames: ['C', 'B'] }, + ] + const takeLocalRequest = vi.fn(() => requests.shift() ?? null) + const deps = makeDeps({ + runLocalRecord, + takeLocalRequest, + now: () => clock, + }) + deps.fetchMock.mockImplementation(async (url: string) => { + if (url.endsWith('/cli/dev/poll')) { + // Keep the first record outside the kill window so newcomers queue. + clock = 60_000 + if (recorded.length >= 1 && requests.length === 0) { + releaseFirst() + } + if (recorded.length >= 2) controller.stopped = true + return jsonResponse({}) + } + return jsonResponse({ ok: true }) + }) + + await runDevListenLoop(config, deps, 'lst_1', controller) + + expect(recorded[0]).toEqual(['A']) + expect(recorded[1]).toEqual(['B', 'C']) + }) + + it('kills a young trigger record when a local request arrives', async () => { + const controller = { stopped: false } + let clock = 0 + const runRecord = vi.fn( + (_t: DevTrigger, signal?: AbortSignal) => + new Promise((_resolve, reject) => { + // Like the real runner, honor a signal aborted before the spawn. + if (signal?.aborted) { + reject(new Error('Record aborted')) + return + } + signal?.addEventListener('abort', () => + reject(new Error('Record aborted')) + ) + }) + ) + const runLocalRecord = vi.fn(async () => {}) + const requests: Array<{ videoNames: string[] } | null> = [ + null, + { videoNames: ['Intro video'] }, + ] + const takeLocalRequest = vi.fn(() => + requests.length > 0 ? (requests.shift() ?? null) : null + ) + const deps = makeDeps({ + runRecord, + runLocalRecord, + takeLocalRequest, + now: () => clock, + }) + let polls = 0 + deps.fetchMock.mockImplementation(async (url: string) => { + if (url.endsWith('/cli/dev/poll')) { + polls += 1 + if (polls === 1) return jsonResponse({ trigger }) + clock = 5_000 // Inside the 10s kill window. + if (runLocalRecord.mock.calls.length > 0) controller.stopped = true + return jsonResponse({}) + } + return jsonResponse({ ok: true }) + }) + + await runDevListenLoop(config, deps, 'lst_1', controller) + + expect(runLocalRecord).toHaveBeenCalledWith( + ['Intro video'], + expect.any(AbortSignal) + ) + // The superseded trigger reported failed. + 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)) + expect(reports.some((r) => r.state === 'failed')).toBe(true) + }) + + it('aborts an active local record on shutdown', async () => { + const controller = { stopped: false } + let aborted = false + const runLocalRecord = vi.fn( + (_names: string[], signal?: AbortSignal) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { + aborted = true + reject(new Error('Record aborted')) + }) + }) + ) + let request: { videoNames: string[] } | null = { videoNames: ['A'] } + const takeLocalRequest = vi.fn(() => { + const taken = request + request = null + return taken + }) + const deps = makeDeps({ runLocalRecord, takeLocalRequest }) + deps.fetchMock.mockImplementation(async (url: string) => { + if (url.endsWith('/cli/dev/poll')) { + if (runLocalRecord.mock.calls.length > 0) controller.stopped = true + return jsonResponse({}) + } + return jsonResponse({ ok: true }) + }) + + await runDevListenLoop(config, deps, 'lst_1', controller) + + expect(aborted).toBe(true) + }) }) describe('reportDevTrigger and deregisterDevListener', () => { diff --git a/cli.ts b/cli.ts index 71f031a..1f1d60c 100644 --- a/cli.ts +++ b/cli.ts @@ -76,10 +76,17 @@ import { OVERLAY_CACHE_DIR_NAME } from './src/htmlRasterizer.js' import { collectEditableFromRecordings } from './src/editableSnapshot.js' import { applyCodegenRequest } from './src/applyCodegen.js' import { + grepMatcher, runDevStartupSync, type DevStartupDeps, type KeptRecording, } from './src/devStartup.js' +import { + buildWatchTargets, + startDevWatcher, + type DevWatchDeps, + type DevWatcherController, +} from './src/devWatch.js' import { entriesFromRecordingData } from './src/editableSnapshot.js' import { LAST_DATA_FILE, @@ -111,6 +118,7 @@ import { import { type DevListenConfig, type DevListenDeps, + type LocalRecordRequest, DevAuthError, SCREENCI_DEV_TOKEN_ENV, deregisterDevListener, @@ -3165,7 +3173,8 @@ async function runTriggeredRecord( async function runPreviewRecordPass( configPath: string | undefined, grepPattern: string | undefined, - verbose: boolean + verbose: boolean, + abortSignal?: AbortSignal ): Promise { const resolvedConfigPath = resolveScreenCIConfigPathOrExit(configPath) const screenciConfig = @@ -3184,8 +3193,19 @@ async function runPreviewRecordPass( try { let playwrightFailure: Error | null = null try { - await run('record', grepArgs, configPath, verbose, false) + await run( + 'record', + grepArgs, + configPath, + verbose, + false, + undefined, + abortSignal + ) } catch (error) { + // A killed (superseded) run uploads nothing: the replacing record is + // already on its way and would race this upload. + if (error instanceof RecordAbortedError) throw error if (!(error instanceof Error)) throw error playwrightFailure = error } @@ -3221,10 +3241,13 @@ export async function runDevCommand( recordKillWindow?: string grep?: string forceRecord?: boolean + /** False disables the source-file watcher (--no-watch). */ + watch?: boolean }, depsOverride: Partial & { machineName?: string startupDeps?: Partial + watchDeps?: Partial } = {} ): Promise { const { screenciConfig, secret, apiUrl } = await requireScreenCISecret( @@ -3250,17 +3273,38 @@ export async function runDevCommand( ? { recordKillWindowMs: killWindowSeconds * 1000 } : {}), } + // Source-file watcher state: created after the startup handshake; the + // codegen/stamp write paths re-baseline files through it so the CLI's own + // writes never trigger a watch re-record. + let watcher: DevWatcherController | null = null + let pendingLocal: LocalRecordRequest | null = null + const enqueueLocalRecord = (videoNames: string[]): void => { + const names = new Set([...(pendingLocal?.videoNames ?? []), ...videoNames]) + if (names.size === 0) return + pendingLocal = { videoNames: [...names] } + } + const deps: DevListenDeps = { fetchFn: fetch, sleep: (ms) => new Promise((r) => setTimeout(r, ms)), logger, runRecord: async (trigger, signal) => { - await runTriggeredRecord( - options.config, - trigger, - options.verbose ?? false, - signal - ) + try { + await runTriggeredRecord( + options.config, + trigger, + options.verbose ?? false, + signal + ) + } finally { + // A record may have learned new source files; watch them too. + await refreshWatchTargets() + } + }, + takeLocalRequest: () => { + const request = pendingLocal + pendingLocal = null + return request }, applyCodegen: async (request) => { const resolvedConfigPath = resolveScreenCIConfigPathOrExit(options.config) @@ -3280,7 +3324,10 @@ export async function runDevCommand( return null } }, - writeFile: (path, content) => writeFileSync(path, content), + writeFile: (path, content) => { + writeFileSync(path, content) + void watcher?.refreshBaseline(path) + }, editableSnapshot: { version: 1, videos: collectEditableFromRecordings(screenciDir), @@ -3308,12 +3355,65 @@ export async function runDevCommand( `Connected as ${pc.bold(`${registration.userName}@${config.machineName}`)} for project "${screenciConfig.projectName}".` ) + const resolvedConfigPath = resolveScreenCIConfigPathOrExit(options.config) + const screenciDir = resolve(dirname(resolvedConfigPath), '.screenci') + const readKeptRecordings = async (): Promise => { + if (!existsSync(screenciDir)) return [] + const recordings: KeptRecording[] = [] + for (const entry of readdirSync(screenciDir)) { + const dir = resolve(screenciDir, entry) + if (statSync(dir, { throwIfNoEntry: false })?.isDirectory() !== true) + continue + const data = await readKeptRecordingData(dir) + if (data !== null) recordings.push({ entry, data }) + } + return recordings + } + const managedMatcher = grepMatcher(options.grep) + const currentWatchTargets = async () => + buildWatchTargets( + await readKeptRecordings(), + managedMatcher, + resolvedConfigPath + ) + const refreshWatchTargets = async (): Promise => { + if (watcher === null) return + try { + await watcher.refreshTargets(await currentWatchTargets()) + } catch { + // Best-effort: a failed refresh keeps the previous watch set. + } + } + + // Machine-local preview record for the watcher's video names: the editor + // sees it through the sync state, and freshly learned sources are watched + // afterwards. + deps.runLocalRecord ??= async (videoNames, signal) => { + await reportDevSyncState( + config, + deps, + registration.listenerId, + videoNames + ).catch(() => {}) + try { + await runPreviewRecordPass( + options.config, + videoNames.map(escapeRegExp).join('|'), + options.verbose ?? false, + signal + ) + } finally { + await reportDevSyncState(config, deps, registration.listenerId, []).catch( + () => {} + ) + await refreshWatchTargets() + } + } + // Startup handshake: bring every managed video up to date (source hash // matches, all editable actions carry editIds) before serving the editor. // Fresh recordings skip the record entirely. try { - const resolvedConfigPath = resolveScreenCIConfigPathOrExit(options.config) - const screenciDir = resolve(dirname(resolvedConfigPath), '.screenci') await runDevStartupSync( { ...(options.grep !== undefined && { grep: options.grep }), @@ -3322,20 +3422,7 @@ export async function runDevCommand( }), }, { - readKeptRecordings: async () => { - if (!existsSync(screenciDir)) return [] - const recordings: KeptRecording[] = [] - for (const entry of readdirSync(screenciDir)) { - const dir = resolve(screenciDir, entry) - if ( - statSync(dir, { throwIfNoEntry: false })?.isDirectory() !== true - ) - continue - const data = await readKeptRecordingData(dir) - if (data !== null) recordings.push({ entry, data }) - } - return recordings - }, + readKeptRecordings, stampEditIds: async (videos) => { const ts = loadTypescript(dirname(screenciDir)) if (ts === null) { @@ -3359,7 +3446,10 @@ export async function runDevCommand( } ) for (const file of plan.files) { - if (file.after !== file.before) writeFileSync(file.path, file.after) + if (file.after !== file.before) { + writeFileSync(file.path, file.after) + void watcher?.refreshBaseline(file.path) + } } if (plan.stamped.length > 0) { writeEditIdCounters(screenciDir, plan.counters) @@ -3393,6 +3483,43 @@ export async function runDevCommand( ) } + // Watch the managed videos' source files (and the config) so saving a test + // source re-records its previews without a manual trigger. + if (options.watch !== false) { + try { + watcher = await startDevWatcher(await currentWatchTargets(), { + logger, + onSourcesChanged: (videoNames) => { + logger.info( + `Source change detected: ${videoNames + .map((name) => `"${name}"`) + .join(', ')}` + ) + enqueueLocalRecord(videoNames) + }, + onConfigChanged: () => { + logger.info( + 'screenci.config.ts changed: re-recording every managed video. ' + + 'Restart screenci dev if the project itself changed.' + ) + void (async () => { + const targets = await currentWatchTargets() + enqueueLocalRecord( + [...targets.files.values()].flatMap((names) => [...names]) + ) + })() + }, + ...depsOverride.watchDeps, + }) + logger.info( + 'Watching test sources for changes (disable with --no-watch).' + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.warn(`Source watching unavailable: ${message}`) + } + } + logger.info( `Waiting for record requests from ${pc.cyan(getDevFrontendUrl())}. Press Ctrl-C to stop.` ) @@ -3401,6 +3528,7 @@ export async function runDevCommand( const shutdown = () => { if (controller.stopped) return controller.stopped = true + watcher?.stop() logger.info('Disconnecting...') void deregisterDevListener(config, deps, registration.listenerId) .catch(() => {}) @@ -3425,6 +3553,7 @@ export async function runDevCommand( throw error } finally { controller.stopped = true + watcher?.stop() process.off('SIGINT', shutdown) process.off('SIGTERM', shutdown) } @@ -4152,6 +4281,11 @@ export async function main() { 're-record every managed video at startup even when the kept ' + 'recordings are up to date' ) + .option( + '--no-watch', + 'do not watch the managed test sources (and screenci.config.ts) for ' + + 'changes that trigger a preview re-record' + ) .action( async (options: { config?: string @@ -4160,6 +4294,7 @@ export async function main() { recordKillWindow?: string grep?: string forceRecord?: boolean + watch?: boolean }) => { await runDevCommand(options) } diff --git a/docs/cli.mdx b/docs/cli.mdx index 05fc633..3e8f039 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -374,6 +374,7 @@ Options: 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. +- `--no-watch`: disable the source-file watcher (see below). - `-v, --verbose`: verbose output. Startup check: `screenci dev` keeps each recording's `data.json` on disk after @@ -388,6 +389,23 @@ 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. +Besides timeline edits this covers the editor's option panels: render and +record option changes are merged into the video's `.renderOptions({...})` / +`.recordOptions({...})` builder calls (the call is added when the section does +not exist yet), and narration text edits are merged into the +`video.narration(...)` declaration. Editing a narration text in a non-default +language converts a flat declaration to the language-major form (the existing +values move under `default`). A names-only narration declaration +(`.narration(['intro'])`) stays app-managed: its content lives in the web app +and is never codegen'd. + +Source watching: while connected, `screenci dev` watches the test source files +backing the managed videos, plus `screenci.config.ts`. Saving a real change to +a source file automatically re-records that file's videos as a preview (the +same freshness hash as the startup check filters out no-op saves and the +CLI's own codegen writes); changing the config re-records every managed video. +Watching follows the `-g/--grep` filter and can be turned off with +`--no-watch`. Setup: diff --git a/docs/editor.md b/docs/editor.md index 629969f..f8bfaf6 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -556,6 +556,38 @@ remaps, 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. +## Option panels and narration text reach code too + +The editor's option panels are codegen'd the same way as timeline edits while +`screenci dev` is connected (the studio config keeps working as the instant +preview and the offline fallback): + +- **Render options** (recording size and roundness, background, aspect ratio, + quality, mouse size/style/motion blur, keyboard shortcut display, narration + box styling, shadow, crop) are merged into the video's + `.renderOptions({...})` builder call. The call is appended to the chain when + the video has none yet; existing keys are updated in place and unrelated + keys are left untouched. +- **Record options** are merged into `.recordOptions({...})` the same way and + trigger a preview re-record, since they change recorded behavior. +- **Narration text** is merged into the `video.narration(...)` declaration: + a new cue key is added, an existing value replaced, and per-cue volume is + written as the `{ cue, volume }` object form (a plain text edit never + upgrades a string cue to an object, and editing the text of an object cue + keeps its other keys). Editing a non-default language converts a flat + (content-major) declaration to the language-major form: the existing values + move under `default` verbatim and the edited language gets its own + sub-object. + +Not codegen'd (app-managed by design): narration voices and uploaded narration +media, cloned-voice samples, on-screen text values, and audio tracks. A +names-only narration declaration (`.narration(['intro'])`) keeps its content in +the web app; editing such a cue never touches code. + +Loop repeats stay locked: an action that runs more than once from a single +call site (keys like `click1#1`) cannot be edited per execution, in the editor +or through codegen. Edit the first iteration or the code itself. + ## Undoing web edits Edits live in your sources, so undoing one is a code change: revert the file diff --git a/src/applyCodegen.spec.ts b/src/applyCodegen.spec.ts new file mode 100644 index 0000000..09fb9a8 --- /dev/null +++ b/src/applyCodegen.spec.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest' +import ts from 'typescript' +import { applyCodegenRequest } from './applyCodegen.js' +import type { EditableSnapshot } from './editableSnapshot.js' + +const FILE = '/proj/demo.screenci.ts' + +const SOURCE = [ + "import { video } from 'screenci'", + '', + "video('Demo', async ({ page }) => {", + " await page.locator('#name').fill('Jane', { editId: 'fill1' })", + '})', + '', +].join('\n') + +const SNAPSHOT: EditableSnapshot = { + version: 1, + videos: { + Demo: [ + { + key: 'fill1', + editId: 'fill1', + locked: false, + defaults: {}, + source: { file: FILE, line: 4 }, + }, + ], + }, +} + +function apply(editJson: string, source: string = SOURCE) { + const writes: Record = {} + applyCodegenRequest( + { + requestId: 'req1', + videoName: 'Demo', + editId: 'edit1', + editJson, + requiresRecord: false, + }, + { + ts, + readFile: (path) => (path === FILE ? source : null), + writeFile: (path, content) => { + writes[path] = content + }, + editableSnapshot: SNAPSHOT, + } + ) + return writes +} + +describe('applyCodegenRequest: options and narration records', () => { + it('writes an optionsEdit as a new .renderOptions call', () => { + const writes = apply( + JSON.stringify({ + type: 'optionsEdit', + id: 'options|renderOptions', + method: 'renderOptions', + values: { fps: 60, mouse: { size: 2 } }, + }) + ) + expect(writes[FILE]).toContain( + "video.renderOptions({ fps: 60, mouse: { size: 2 } })('Demo'" + ) + }) + + it('merges an optionsEdit into an existing .recordOptions call', () => { + const source = SOURCE.replace( + "video('Demo'", + "video.recordOptions({ headless: false })('Demo'" + ) + const writes = apply( + JSON.stringify({ + type: 'optionsEdit', + id: 'options|recordOptions', + method: 'recordOptions', + values: { headless: true, slowMo: 50 }, + }), + source + ) + expect(writes[FILE]).toContain( + "video.recordOptions({ headless: true, slowMo: 50 })('Demo'" + ) + }) + + it('does not rewrite the file when the options already match', () => { + const source = SOURCE.replace( + "video('Demo'", + "video.renderOptions({ fps: 60 })('Demo'" + ) + const writes = apply( + JSON.stringify({ + type: 'optionsEdit', + id: 'options|renderOptions', + method: 'renderOptions', + values: { fps: 60 }, + }), + source + ) + expect(writes).toEqual({}) + }) + + it('writes a narrationEdit into the declaration, adding the section', () => { + const writes = apply( + JSON.stringify({ + type: 'narrationEdit', + id: 'narration|intro|default', + cueName: 'intro', + lang: 'default', + value: 'Hi there', + }) + ) + expect(writes[FILE]).toContain( + "video.narration({ intro: 'Hi there' })('Demo'" + ) + }) + + it('converts a content-major declaration on a non-default lang edit', () => { + const source = SOURCE.replace( + "video('Demo'", + "video.narration({ intro: 'Hi' })('Demo'" + ) + const writes = apply( + JSON.stringify({ + type: 'narrationEdit', + id: 'narration|intro|fi', + cueName: 'intro', + lang: 'fi', + value: 'Moi', + }), + source + ) + expect(writes[FILE]).toContain( + "video.narration({ default: { intro: 'Hi' }, fi: { intro: 'Moi' } })" + ) + }) + + it('throws with the reason when a narration edit is app-managed', () => { + const source = SOURCE.replace( + "video('Demo'", + "video.narration(['intro'])('Demo'" + ) + expect(() => + apply( + JSON.stringify({ + type: 'narrationEdit', + id: 'narration|intro|default', + cueName: 'intro', + lang: 'default', + value: 'Hi', + }), + source + ) + ).toThrow(/app-managed/) + }) +}) diff --git a/src/applyCodegen.ts b/src/applyCodegen.ts index ab72334..933c4e8 100644 --- a/src/applyCodegen.ts +++ b/src/applyCodegen.ts @@ -2,8 +2,9 @@ * 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 + * mediaEdit, zoomEdit, gapSpanEdit, gapPointEdit, overlayDeclEdit, + * optionsEdit, narrationEdit or renameEdit). 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 @@ -43,9 +44,10 @@ export function applyCodegenRequest( } const split = splitTimelineEditsByVideo({ - [request.videoName]: { version: 3, edits: [record] }, + [request.videoName]: { version: 4, edits: [record] }, }) + const studioVideo = split.studioOptions[request.videoName] const plan = planCodeSync( { // The codegen path carries no web action-param state to diff; the @@ -58,6 +60,22 @@ export function applyCodegenRequest( removedCodifyEdits: split.removedCodify, renames: split.renames, overlayDeclEdits: split.overlayDecls, + ...(studioVideo !== undefined && { + studioSync: { + videos: { + [request.videoName]: { + ...studioVideo, + content: { + narration: false, + text: false, + audio: false, + assets: false, + }, + }, + }, + }, + }), + narrationEdits: split.narrationEdits, }, { ts: deps.ts, readFile: deps.readFile } ) diff --git a/src/codeSync.spec.ts b/src/codeSync.spec.ts index 75dc66c..e8e92b4 100644 --- a/src/codeSync.spec.ts +++ b/src/codeSync.spec.ts @@ -1145,3 +1145,102 @@ describe('planCodeSync: studio render/record option codify', () => { expect(result.unappliable).toHaveLength(1) }) }) + +describe('planCodeSync: narration cue value codify', () => { + it('adds a .narration section to the video builder call when missing', () => { + const result = plan( + inputWith({ + narrationEdits: { + Demo: [ + { + type: 'narrationEdit', + id: 'narration|intro|default', + cueName: 'intro', + lang: 'default', + value: 'Hi there', + }, + ], + }, + }) + ) + const after = afterFor(result, FILE) + expect(after).toContain("video.narration({ intro: 'Hi there' })('Demo'") + expect(result.applied).toHaveLength(1) + expect(result.fullyAppliedVideos).toEqual(['Demo']) + }) + + it('converts a content-major declaration to language-major on a lang edit', () => { + const narratedSource = SOURCE.replace( + "video('Demo'", + "video.narration({ intro: 'Hi' })('Demo'" + ) + const files = { [FILE]: narratedSource } + const result = plan( + inputWith({ + narrationEdits: { + Demo: [ + { + type: 'narrationEdit', + id: 'narration|intro|fi', + cueName: 'intro', + lang: 'fi', + value: 'Moi', + }, + ], + }, + }), + files + ) + const after = afterFor(result, FILE) + expect(after).toContain( + "video.narration({ default: { intro: 'Hi' }, fi: { intro: 'Moi' } })" + ) + expect(result.applied).toHaveLength(1) + }) + + it('marks a names-only declaration app-managed', () => { + const namesOnlySource = SOURCE.replace( + "video('Demo'", + "video.narration(['intro'])('Demo'" + ) + const result = plan( + inputWith({ + narrationEdits: { + Demo: [ + { + type: 'narrationEdit', + id: 'narration|intro|default', + cueName: 'intro', + lang: 'default', + value: 'Hi', + }, + ], + }, + }), + { [FILE]: namesOnlySource } + ) + expect(result.files).toHaveLength(0) + expect(result.unappliable).toHaveLength(1) + expect(result.unappliable[0]!.reason).toContain('app-managed') + }) + + it('marks the video unappliable when its declaration is absent', () => { + const result = plan( + inputWith({ + narrationEdits: { + Ghost: [ + { + type: 'narrationEdit', + id: 'narration|intro|default', + cueName: 'intro', + lang: 'default', + value: 'Hi', + }, + ], + }, + }) + ) + expect(result.files).toHaveLength(0) + expect(result.unappliable).toHaveLength(1) + }) +}) diff --git a/src/codeSync.ts b/src/codeSync.ts index 1301a1a..9f5865b 100644 --- a/src/codeSync.ts +++ b/src/codeSync.ts @@ -29,9 +29,11 @@ import type { CodifyEditsByVideo, EditableOverridesByVideo, EditableSnapshot, + NarrationEditsByVideo, OverlayDeclEditsByVideo, RenamesByVideo, } from './editableSnapshot.js' +import { isLanguageKey } from './declare.js' import type { CodifyEdit, GapPointEdit, @@ -59,6 +61,7 @@ import { renameEditId as renameEditIdInSource, removeOption, setBuilderOptions, + setNarrationValue, setOptionValue, setOverlayDeclProps, splitWaitEdit, @@ -215,6 +218,13 @@ export type CodeSyncInput = { * Optional so existing callers (and tests) need not pass it. */ studioSync?: StudioSyncState + /** + * Narration cue value edits, keyed by video name. Codified into the + * `video.narration(...)` declaration argument (added when missing, converted + * to the language-major form when a non-default language is edited). + * Optional so existing callers (and tests) need not pass it. + */ + narrationEdits?: NarrationEditsByVideo } /** A slug that is safe as a code identity and a wire key. */ @@ -1081,6 +1091,7 @@ export function planCodeSync( ...Object.keys(input.renames), ...Object.keys(input.overlayDeclEdits ?? {}), ...Object.keys(input.studioSync?.videos ?? {}), + ...Object.keys(input.narrationEdits ?? {}), ]), ] @@ -1475,6 +1486,51 @@ export function planCodeSync( } } } + + // ── Narration cue value edits (codify into video.narration) ──────────── + const narrationEdits = input.narrationEdits?.[videoName] ?? [] + if (narrationEdits.length > 0) { + const file = declaringFile() + for (const edit of narrationEdits) { + let reason = `locked narration cue '${edit.cueName}' on video '${videoName}'` + const ok = + file !== null && + tryApply(file, (ctx) => { + const result = setNarrationValue( + ctx, + videoName, + { + cueName: edit.cueName, + lang: edit.lang, + ...(edit.isDefault !== undefined && { + isDefault: edit.isDefault, + }), + value: edit.value, + }, + isLanguageKey + ) + if (result.kind === 'edits') return result.edits + if (result.kind === 'appManaged') { + reason = + `narration cue '${edit.cueName}' on video '${videoName}' ` + + `is app-managed (names-only declaration)` + } + return null + }) + if (ok) { + applied.push({ + videoName, + file: file!, + description: + `set narration cue '${edit.cueName}' (${edit.lang}) ` + + `on video '${videoName}'`, + }) + bump(appliedCounts, videoName) + } else { + markUnappliable(reason) + } + } + } } const files = [...texts.entries()] diff --git a/src/codemod.spec.ts b/src/codemod.spec.ts index e3ab00f..c1899bf 100644 --- a/src/codemod.spec.ts +++ b/src/codemod.spec.ts @@ -13,6 +13,7 @@ import { removeFullLine, removeOption, setBuilderOptions, + setNarrationValue, setOptionValue, setOverlayDeclProps, statementAtLine, @@ -436,6 +437,333 @@ describe('setBuilderOptions', () => { }) }) +describe('setNarrationValue', () => { + const isLanguageKey = (key: string): boolean => + key === 'default' || ['fr', 'de', 'en'].includes(key) + + function narrate( + source: string, + edit: { cueName: string; lang: string; isDefault?: boolean; value: unknown } + ) { + return setNarrationValue(ctxOf(source), 'Demo', edit, isLanguageKey) + } + + function narrated( + source: string, + edit: { cueName: string; lang: string; isDefault?: boolean; value: unknown } + ): string { + const result = narrate(source, edit) + expect(result.kind).toBe('edits') + if (result.kind !== 'edits') throw new Error('unreachable') + return applyTextEdits(source, result.edits) + } + + it('adds a content-major .narration section when missing (default lang)', () => { + const source = "video('Demo', async () => {})" + expect( + narrated(source, { cueName: 'intro', lang: 'default', value: 'Hi' }) + ).toBe("video.narration({ intro: 'Hi' })('Demo', async () => {})") + }) + + it('adds a language-major .narration section when missing (specific lang)', () => { + const source = "video.renderOptions({ fps: 30 })('Demo', async () => {})" + expect( + narrated(source, { cueName: 'intro', lang: 'fr', value: 'Salut' }) + ).toBe( + 'video.renderOptions({ fps: 30 })' + + ".narration({ fr: { intro: 'Salut' } })('Demo', async () => {})" + ) + }) + + it('changes an existing content-major value in the default lang', () => { + const source = + "video.narration({ intro: 'Hi', cta: 'Go' })('Demo', async () => {})" + expect( + narrated(source, { cueName: 'intro', lang: 'default', value: 'Hello' }) + ).toBe( + "video.narration({ intro: 'Hello', cta: 'Go' })('Demo', async () => {})" + ) + }) + + it('adds a new cue key to a content-major object', () => { + const source = "video.narration({ intro: 'Hi' })('Demo', async () => {})" + expect( + narrated(source, { cueName: 'outro', lang: 'default', value: 'Bye' }) + ).toBe( + "video.narration({ intro: 'Hi', outro: 'Bye' })('Demo', async () => {})" + ) + }) + + it('converts content-major to language-major on a non-default lang edit', () => { + const source = + "video.narration({ intro: 'Hi', cta: 'Go' })('Demo', async () => {})" + expect( + narrated(source, { cueName: 'intro', lang: 'fr', value: 'Salut' }) + ).toBe( + "video.narration({ default: { intro: 'Hi', cta: 'Go' }, " + + "fr: { intro: 'Salut' } })('Demo', async () => {})" + ) + }) + + it('populates an empty declaration in the language-major form', () => { + const source = "video.narration({})('Demo', async () => {})" + expect( + narrated(source, { cueName: 'intro', lang: 'fr', value: 'Salut' }) + ).toBe( + "video.narration({ fr: { intro: 'Salut' } })('Demo', async () => {})" + ) + }) + + it('merges into an existing language of a language-major object', () => { + const source = + "video.narration({ default: { intro: 'Hi' }, fr: { intro: 'Salut' } })" + + "('Demo', async () => {})" + expect( + narrated(source, { cueName: 'intro', lang: 'fr', value: 'Coucou' }) + ).toBe( + "video.narration({ default: { intro: 'Hi' }, fr: { intro: 'Coucou' } })" + + "('Demo', async () => {})" + ) + }) + + it('adds a missing language key to a language-major object', () => { + const source = + "video.narration({ default: { intro: 'Hi' } })('Demo', async () => {})" + expect( + narrated(source, { cueName: 'intro', lang: 'de', value: 'Hallo' }) + ).toBe( + "video.narration({ default: { intro: 'Hi' }, de: { intro: 'Hallo' } })" + + "('Demo', async () => {})" + ) + }) + + it('adds a new cue to an existing language sub-object', () => { + const source = + "video.narration({ fr: { intro: 'Salut' } })('Demo', async () => {})" + expect( + narrated(source, { cueName: 'cta', lang: 'fr', value: 'Allez' }) + ).toBe( + "video.narration({ fr: { intro: 'Salut', cta: 'Allez' } })" + + "('Demo', async () => {})" + ) + }) + + it('updates the default sub-object of a language-major declaration', () => { + const source = + "video.narration({ default: { intro: 'Hi' } })('Demo', async () => {})" + expect( + narrated(source, { cueName: 'intro', lang: 'default', value: 'Hello' }) + ).toBe( + "video.narration({ default: { intro: 'Hello' } })('Demo', async () => {})" + ) + }) + + it('merges an object value into an existing cue object, keeping other keys', () => { + const source = + "video.narration({ intro: { cue: 'Hi', volume: 0.5 } })" + + "('Demo', async () => {})" + expect( + narrated(source, { + cueName: 'intro', + lang: 'default', + value: { cue: 'Hello' }, + }) + ).toBe( + "video.narration({ intro: { cue: 'Hello', volume: 0.5 } })" + + "('Demo', async () => {})" + ) + }) + + it('keeps declared metadata when a plain text edit hits an object cue', () => { + const source = + "video.narration({ intro: { cue: 'Hi', volume: 0.5 } })" + + "('Demo', async () => {})" + expect( + narrated(source, { cueName: 'intro', lang: 'default', value: 'Hello' }) + ).toBe( + "video.narration({ intro: { cue: 'Hello', volume: 0.5 } })" + + "('Demo', async () => {})" + ) + }) + + it('writes a cue-only object value as a plain string', () => { + const source = "video.narration({ intro: 'Hi' })('Demo', async () => {})" + expect( + narrated(source, { + cueName: 'intro', + lang: 'default', + value: { cue: 'Hello' }, + }) + ).toBe("video.narration({ intro: 'Hello' })('Demo', async () => {})") + }) + + it('upgrades a plain string cue to an object when metadata arrives', () => { + const source = "video.narration({ intro: 'Hi' })('Demo', async () => {})" + expect( + narrated(source, { + cueName: 'intro', + lang: 'default', + value: { cue: 'Hi', volume: 0.5 }, + }) + ).toBe( + "video.narration({ intro: { cue: 'Hi', volume: 0.5 } })" + + "('Demo', async () => {})" + ) + }) + + it('is a no-op when the string value already matches', () => { + const source = "video.narration({ intro: 'Hi' })('Demo', async () => {})" + const result = narrate(source, { + cueName: 'intro', + lang: 'default', + value: 'Hi', + }) + expect(result).toEqual({ kind: 'edits', edits: [] }) + }) + + it('is a no-op when the object value already matches', () => { + const source = + "video.narration({ intro: { cue: 'Hi', volume: 0.5 } })" + + "('Demo', async () => {})" + const result = narrate(source, { + cueName: 'intro', + lang: 'default', + value: { cue: 'Hi', volume: 0.5 }, + }) + expect(result).toEqual({ kind: 'edits', edits: [] }) + }) + + it('reports a names-only declaration as app-managed', () => { + const source = "video.narration(['intro', 'cta'])('Demo', async () => {})" + expect( + narrate(source, { cueName: 'intro', lang: 'default', value: 'Hi' }) + ).toEqual({ kind: 'appManaged' }) + }) + + it('is unsupported when the declaration argument is not a literal', () => { + const source = "video.narration(cues)('Demo', async () => {})" + expect( + narrate(source, { cueName: 'intro', lang: 'default', value: 'Hi' }) + ).toEqual({ kind: 'unsupported' }) + }) + + it('is unsupported when the declaration contains a spread', () => { + const source = + "video.narration({ ...base, intro: 'Hi' })('Demo', async () => {})" + expect( + narrate(source, { cueName: 'intro', lang: 'default', value: 'Hello' }) + ).toEqual({ kind: 'unsupported' }) + }) + + it('is unsupported when the video declaration is missing or ambiguous', () => { + expect( + setNarrationValue( + ctxOf("video('Other', async () => {})"), + 'Demo', + { cueName: 'intro', lang: 'default', value: 'Hi' }, + isLanguageKey + ) + ).toEqual({ kind: 'unsupported' }) + const duplicated = [ + "video('Demo', async () => {})", + "video('Demo', async () => {})", + ].join('\n') + expect( + setNarrationValue( + ctxOf(duplicated), + 'Demo', + { cueName: 'intro', lang: 'default', value: 'Hi' }, + isLanguageKey + ) + ).toEqual({ kind: 'unsupported' }) + }) + + it('treats a default-language edit as the shared content-major value', () => { + const source = "video.narration({ intro: 'Hi' })('Demo', async () => {})" + expect( + narrated(source, { + cueName: 'intro', + lang: 'en', + isDefault: true, + value: 'Hello', + }) + ).toBe("video.narration({ intro: 'Hello' })('Demo', async () => {})") + }) + + it('prefers an explicit language key over default for a default-language edit', () => { + const source = + "video.narration({ default: { intro: 'Hi' }, en: { intro: 'Hey' } })" + + "('Demo', async () => {})" + expect( + narrated(source, { + cueName: 'intro', + lang: 'en', + isDefault: true, + value: 'Hello', + }) + ).toBe( + "video.narration({ default: { intro: 'Hi' }, en: { intro: 'Hello' } })" + + "('Demo', async () => {})" + ) + }) + + it('routes a default-language edit without an explicit key to default', () => { + const source = + "video.narration({ default: { intro: 'Hi' }, fr: { intro: 'Salut' } })" + + "('Demo', async () => {})" + expect( + narrated(source, { + cueName: 'intro', + lang: 'en', + isDefault: true, + value: 'Hello', + }) + ).toBe( + "video.narration({ default: { intro: 'Hello' }, fr: { intro: 'Salut' } })" + + "('Demo', async () => {})" + ) + }) + + it('adds a default sub-object for a default-language edit when missing', () => { + const source = + "video.narration({ fr: { intro: 'Salut' } })('Demo', async () => {})" + expect( + narrated(source, { + cueName: 'intro', + lang: 'en', + isDefault: true, + value: 'Hello', + }) + ).toBe( + "video.narration({ fr: { intro: 'Salut' }, default: { intro: 'Hello' } })" + + "('Demo', async () => {})" + ) + }) + + it('starts a missing section content-major on a default-language edit', () => { + const source = "video('Demo', async () => {})" + expect( + narrated(source, { + cueName: 'intro', + lang: 'en', + isDefault: true, + value: 'Hi', + }) + ).toBe("video.narration({ intro: 'Hi' })('Demo', async () => {})") + }) + + it('quotes cue names that are not identifiers', () => { + const source = "video('Demo', async () => {})" + expect( + narrated(source, { + cueName: 'step one', + lang: 'default', + value: 'Hi', + }) + ).toBe("video.narration({ 'step one': 'Hi' })('Demo', async () => {})") + }) +}) + describe('setOverlayDeclProps', () => { function applyAll(source: string, edits: TextEdit[] | null): string { expect(edits).not.toBeNull() diff --git a/src/codemod.ts b/src/codemod.ts index f77bb5b..03193f3 100644 --- a/src/codemod.ts +++ b/src/codemod.ts @@ -1102,6 +1102,274 @@ export function setBuilderOptions( ] } +/** `name` when it is a plain identifier, else a quoted property name. */ +function propertyNameSource(name: string): string { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) + ? name + : `'${name.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` +} + +/** + * The top-level property names of an object literal, or null when the literal + * contains a spread or a computed name (its key set cannot be known + * statically). + */ +function objectLiteralKeys( + ts: TsModule, + object: TS.ObjectLiteralExpression +): string[] | null { + const keys: string[] = [] + for (const property of object.properties) { + if (ts.isSpreadAssignment(property)) return null + const name = property.name + if (name === undefined) return null + if (ts.isIdentifier(name) || ts.isStringLiteral(name)) { + keys.push(name.text) + } else { + return null + } + } + return keys +} + +/** One narration cue value change to write into a declaration argument. */ +export type NarrationValueEdit = { + cueName: string + /** A language code, or `'default'` for the shared value. */ + lang: string + /** + * True when `lang` is the video's default language: the edit targets the + * shared (content-major or `default`) value unless the declaration carries + * an explicit `[lang]` sub-object. + */ + isDefault?: boolean + value: unknown +} + +/** + * Result of {@link setNarrationValue}: computed edits (empty when already + * up to date), `appManaged` for a names-only declaration whose content lives + * in the web app, or `unsupported` when the declaration resists a mechanical + * edit. + */ +export type NarrationValueResult = + | { kind: 'edits'; edits: TextEdit[] } + | { kind: 'appManaged' } + | { kind: 'unsupported' } + +/** Merge one cue value into a `name -> value` object literal. */ +function mergeCueIntoObject( + ctx: CodemodContext, + object: TS.ObjectLiteralExpression, + edit: NarrationValueEdit, + valueSource: string +): NarrationValueResult { + const { ts } = ctx + const { property, unsafe } = findProperty(ts, object, edit.cueName) + if (unsafe) return { kind: 'unsupported' } + if (property === null) { + return { + kind: 'edits', + edits: [ + insertPropertyEdit( + object, + `${propertyNameSource(edit.cueName)}: ${valueSource}` + ), + ], + } + } + const initializer = property.initializer + // Object (or string, onto an object cue) value onto an existing object + // literal: deep-merge only the sent keys, so an edited cue text keeps a + // declared voice or volume, and vice versa. + const mergeValue: Record | null = + typeof edit.value === 'object' && + edit.value !== null && + !Array.isArray(edit.value) + ? (edit.value as Record) + : typeof edit.value === 'string' + ? { cue: edit.value } + : null + if (mergeValue !== null && ts.isObjectLiteralExpression(initializer)) { + const objectText = ctx.source.slice( + initializer.getStart(), + initializer.getEnd() + ) + const merged = mergeObjectLiteralText(ts, objectText, mergeValue) + if (merged === null) return { kind: 'unsupported' } + if (merged === objectText) return { kind: 'edits', edits: [] } + return { + kind: 'edits', + edits: [ + { + start: initializer.getStart(), + end: initializer.getEnd(), + replacement: merged, + }, + ], + } + } + // Plain replacement otherwise: string onto string, and the shape upgrades + // (string value onto an object, object value onto a plain string). + if ( + typeof edit.value === 'string' && + ts.isStringLiteralLike(initializer) && + initializer.text === edit.value + ) { + return { kind: 'edits', edits: [] } // idempotent no-op + } + return { + kind: 'edits', + edits: [ + { + start: initializer.getStart(), + end: initializer.getEnd(), + replacement: valueSource, + }, + ], + } +} + +/** + * Write one narration cue value into the `video.narration(...)` declaration + * of `videoName`, handling every declaration shape (see declare.ts): + * + * - Declaration missing: a `.narration({...})` call is appended to the chain, + * content-major for a default-language edit and language-major otherwise. + * - Content-major object: the cue is merged in place for a default-language + * edit; editing a non-default language rewrites the argument to the + * language-major form, with the existing object text preserved verbatim + * under `default`. + * - Language-major object: the cue is merged into the language's sub-object + * when present. A default-language edit without an explicit `[lang]` key + * targets the shared `default` sub-object (added when missing); any other + * missing language key is added. + * - Names-only array: content is app-managed by design; reported as such. + * + * Conservative by design: `unsupported` whenever the declaration or the video + * call resists a mechanical edit (missing/ambiguous declaration, spreads, + * shorthand properties, non-literal arguments). Idempotent: re-applying the + * same value produces no edit. + */ +export function setNarrationValue( + ctx: CodemodContext, + videoName: string, + edit: NarrationValueEdit, + isLanguageKey: (key: string) => boolean +): NarrationValueResult { + const { ts } = ctx + // A `{ cue }` object with no other keys is the plain-string spelling: keep + // declarations minimal (a text edit never upgrades a string cue to an + // object). + if ( + typeof edit.value === 'object' && + edit.value !== null && + !Array.isArray(edit.value) + ) { + const valueKeys = Object.keys(edit.value) + const cueOnly = (edit.value as { cue?: unknown }).cue + if ( + valueKeys.length === 1 && + valueKeys[0] === 'cue' && + typeof cueOnly === 'string' + ) { + edit = { ...edit, value: cueOnly } + } + } + const valueSource = valueToSource(edit.value) + if (valueSource === null) return { kind: 'unsupported' } + const call = findVideoCall(ctx, videoName) + if (call === null) return { kind: 'unsupported' } + const cueProp = propertyNameSource(edit.cueName) + const langProp = propertyNameSource(edit.lang) + const isDefault = edit.isDefault === true || edit.lang === 'default' + const existing = findMethodCallInChain(ts, call.expression, 'narration') + if (existing === null) { + const objectSource = isDefault + ? `{ ${cueProp}: ${valueSource} }` + : `{ ${langProp}: { ${cueProp}: ${valueSource} } }` + const insertAt = call.expression.getEnd() + return { + kind: 'edits', + edits: [ + { + start: insertAt, + end: insertAt, + replacement: `.narration(${objectSource})`, + }, + ], + } + } + const arg = existing.arguments[0] + if (arg === undefined) return { kind: 'unsupported' } + if (ts.isArrayLiteralExpression(arg)) return { kind: 'appManaged' } + if (!ts.isObjectLiteralExpression(arg)) return { kind: 'unsupported' } + const keys = objectLiteralKeys(ts, arg) + if (keys === null) return { kind: 'unsupported' } + const languageMajor = keys.length > 0 && keys.every(isLanguageKey) + if (!languageMajor) { + if (isDefault || keys.length === 0) { + if (keys.length === 0 && !isDefault) { + // Empty declaration: start it directly in the language-major form. + return { + kind: 'edits', + edits: [ + { + start: arg.getStart(), + end: arg.getEnd(), + replacement: `{ ${langProp}: { ${cueProp}: ${valueSource} } }`, + }, + ], + } + } + return mergeCueIntoObject(ctx, arg, edit, valueSource) + } + // Content-major declaration edited in a specific language: convert to the + // language-major form, keeping the existing object text verbatim as the + // shared `default` values. + const existingText = ctx.source.slice(arg.getStart(), arg.getEnd()) + return { + kind: 'edits', + edits: [ + { + start: arg.getStart(), + end: arg.getEnd(), + replacement: + `{ default: ${existingText}, ` + + `${langProp}: { ${cueProp}: ${valueSource} } }`, + }, + ], + } + } + // Language-major: an explicit `[lang]` sub-object wins; a default-language + // edit without one targets the shared `default` sub-object. + const explicit = findProperty(ts, arg, edit.lang) + if (explicit.unsafe) return { kind: 'unsupported' } + let property = explicit.property + let targetProp = langProp + if (property === null && isDefault && edit.lang !== 'default') { + const shared = findProperty(ts, arg, 'default') + if (shared.unsafe) return { kind: 'unsupported' } + property = shared.property + targetProp = 'default' + } + if (property === null) { + return { + kind: 'edits', + edits: [ + insertPropertyEdit( + arg, + `${targetProp}: { ${cueProp}: ${valueSource} }` + ), + ], + } + } + if (!ts.isObjectLiteralExpression(property.initializer)) { + return { kind: 'unsupported' } + } + return mergeCueIntoObject(ctx, property.initializer, edit, valueSource) +} + /** * Remove the named top-level keys from an object-literal source text. Missing * keys are skipped. Returns the new source, or null when the object resists a diff --git a/src/devListen.ts b/src/devListen.ts index 6bba123..637c3b9 100644 --- a/src/devListen.ts +++ b/src/devListen.ts @@ -9,6 +9,12 @@ * 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). + * + * The record slot also serves machine-local requests raised by the + * source-file watcher (devWatch.ts): those share the kill-window and queue + * semantics, two queued local requests merge their video names, and nothing + * is reported to the backend trigger channel (the editor sees the run through + * the sync state). */ export const DEV_TOKEN_HEADER = 'X-ScreenCI-Dev-Token' @@ -69,6 +75,12 @@ export type DevListenLogger = { error: (message: string) => void } +/** + * A record request raised on this machine (the source-file watcher), not by + * the backend. Carries the video names to re-record as previews. + */ +export type LocalRecordRequest = { videoNames: string[] } + export type DevListenDeps = { fetchFn: typeof fetch sleep: (ms: number) => Promise @@ -81,6 +93,15 @@ export type DevListenDeps = { runRecord: (trigger: DevTrigger, signal?: AbortSignal) => Promise /** Applies one codegen request to the test source; throws on failure. */ applyCodegen?: (request: DevCodegenRequest) => Promise + /** + * Drains one pending machine-local record request (from the source-file + * watcher). Checked every poll iteration; local requests share the record + * slot and kill-window semantics with backend triggers, but report nothing + * to the trigger channel (editor visibility comes from the sync state). + */ + takeLocalRequest?: () => LocalRecordRequest | null + /** Runs a machine-local preview record for the given video names. */ + runLocalRecord?: (videoNames: string[], signal?: AbortSignal) => Promise /** Registers a heartbeat timer during a run; returns a cancel function. */ setIntervalFn?: (fn: () => void, ms: number) => () => void /** Time source, injectable for tests. */ @@ -232,9 +253,14 @@ export async function deregisterDevListener( await postDev(config, deps, '/cli/dev/deregister', { listenerId }) } +/** The work occupying the single record slot. */ +type RecordJob = + | { kind: 'trigger'; trigger: DevTrigger } + | { kind: 'local'; videoNames: string[] } + /** One background record run. */ type ActiveRecord = { - trigger: DevTrigger + job: RecordJob startedAt: number abort: AbortController /** Set before aborting so the completion reports "superseded", not "done". */ @@ -242,6 +268,12 @@ type ActiveRecord = { done: Promise } +function jobLabel(job: RecordJob): string { + return job.kind === 'trigger' + ? `"${job.trigger.videoName}"` + : job.videoNames.map((name) => `"${name}"`).join(', ') +} + async function handleCodegenRequest( config: DevListenConfig, deps: DevListenDeps, @@ -312,13 +344,115 @@ export async function runDevListenLoop( const now = deps.now ?? Date.now let active: ActiveRecord | null = null - let queued: DevTrigger | null = null + let queued: RecordJob | null = null let lastActivityAt = -Infinity - const startRecord = (trigger: DevTrigger): void => { + const runTriggerJob = async ( + slot: ActiveRecord, + trigger: DevTrigger, + abort: AbortController + ): Promise => { + 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(() => {}) + } + } + + const runLocalJob = async ( + slot: ActiveRecord, + videoNames: string[], + abort: AbortController + ): Promise => { + // Local jobs report nothing to the trigger channel: the editor sees them + // through the sync state the runner reports. + deps.logger.info(`Source change: re-recording ${jobLabel(slot.job)}.`) + try { + if (deps.runLocalRecord === undefined) { + throw new Error('This listener does not support local records') + } + await deps.runLocalRecord(videoNames, abort.signal) + if (!slot.superseded) { + deps.logger.info(`Finished re-recording ${jobLabel(slot.job)}.`) + } + } catch (error) { + if (!slot.superseded) { + const message = error instanceof Error ? error.message : String(error) + deps.logger.error( + `Local record for ${jobLabel(slot.job)} failed: ${message}` + ) + } + } + } + + const startRecord = (job: RecordJob): void => { const abort = new AbortController() const slot: ActiveRecord = { - trigger, + job, startedAt: now(), abort, superseded: false, @@ -326,116 +460,72 @@ export async function runDevListenLoop( } 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}).` - ) + switch (job.kind) { + case 'trigger': + await runTriggerJob(slot, job.trigger, abort) + break + case 'local': + await runLocalJob(slot, job.videoNames, abort) + break + default: { + const exhaustive: never = job + throw new Error(`Unknown record job ${String(exhaustive)}`) } - } 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) } + if (active === slot) active = null + const next = queued + queued = null + if (next !== null && !controller.stopped) startRecord(next) })() } - const acceptTrigger = async (trigger: DevTrigger): Promise => { + const acceptJob = async (job: RecordJob): Promise => { if (active === null) { - startRecord(trigger) + startRecord(job) 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(() => {}) + if (queued.kind === 'local' && job.kind === 'local') { + // Two pending local requests merge: re-record the union of names. + job = { + kind: 'local', + videoNames: [...new Set([...queued.videoNames, ...job.videoNames])], + } + } else if (queued.kind === 'trigger') { + // Latest wins: a previously queued trigger is dropped for the new job. + await reportDevTrigger( + config, + deps, + listenerId, + queued.trigger.triggerId, + 'failed', + SUPERSEDED_RECORD_MESSAGE + ).catch(() => {}) + } } - queued = trigger + queued = job if (now() - active.startedAt < killWindowMs) { - // Young run: kill it, the slot's completion starts the queued trigger. + // Young run: kill it, the slot's completion starts the queued job. deps.logger.info( - `Killing the record of "${active.trigger.videoName}" for a newer request.` + `Killing the record of ${jobLabel(active.job)} for a newer request.` ) active.superseded = true active.abort.abort() } else { - deps.logger.info( - `Queued "${trigger.videoName}" after the record in progress.` - ) + deps.logger.info(`Queued ${jobLabel(job)} after the record in progress.`) } } while (!controller.stopped) { let delayMs = pollIntervalMs + // Machine-local record requests (source-file watcher) are drained before + // the poll so a backend hiccup cannot stall them. They share the record + // slot with backend triggers and count as activity for fast polling. + const local = deps.takeLocalRequest?.() ?? null + if (local !== null && local.videoNames.length > 0) { + lastActivityAt = now() + await acceptJob({ kind: 'local', videoNames: local.videoNames }) + } try { const result = await pollDevListener(config, deps, listenerId) if (result.codegenRequests.length > 0 || result.trigger !== null) { @@ -449,7 +539,7 @@ export async function runDevListenLoop( await handleCodegenRequest(config, deps, listenerId, request) } if (result.trigger !== null && !controller.stopped) { - await acceptTrigger(result.trigger) + await acceptJob({ kind: 'trigger', trigger: result.trigger }) } } catch (error) { if (error instanceof DevAuthError) { diff --git a/src/devStartup.ts b/src/devStartup.ts index 84e4da7..8b186d1 100644 --- a/src/devStartup.ts +++ b/src/devStartup.ts @@ -68,7 +68,10 @@ export type DevStartupResult = { missingEditIds: string[] } -function grepMatcher(grep: string | undefined): (name: string) => boolean { +/** Matcher for `--grep`-style filters; shared with the source-file watcher. */ +export function grepMatcher( + grep: string | undefined +): (name: string) => boolean { if (grep === undefined) return () => true try { const regex = new RegExp(grep) diff --git a/src/devWatch.spec.ts b/src/devWatch.spec.ts new file mode 100644 index 0000000..b4007a1 --- /dev/null +++ b/src/devWatch.spec.ts @@ -0,0 +1,261 @@ +import { describe, expect, it, vi } from 'vitest' +import { + buildWatchTargets, + startDevWatcher, + type DevWatchDeps, + type WatchTargets, +} from './devWatch.js' +import type { KeptRecording } from './devStartup.js' + +const CONFIG = '/proj/screenci.config.ts' + +function kept(videoName: string, sourceFilePath?: string): KeptRecording { + return { + entry: videoName, + data: { + metadata: { + videoName, + ...(sourceFilePath !== undefined && { sourceFilePath }), + }, + } as KeptRecording['data'], + } +} + +describe('buildWatchTargets', () => { + it('maps source files to the video names they back, grep-filtered', () => { + const targets = buildWatchTargets( + [ + kept('Demo', '/proj/demo.screenci.ts'), + kept('Tour', '/proj/demo.screenci.ts'), + kept('Other', '/proj/other.screenci.ts'), + kept('Skipped', '/proj/skipped.screenci.ts'), + kept('NoSource'), + ], + (name) => name !== 'Skipped', + CONFIG + ) + expect([...targets.files.keys()]).toEqual([ + '/proj/demo.screenci.ts', + '/proj/other.screenci.ts', + ]) + expect([...targets.files.get('/proj/demo.screenci.ts')!]).toEqual([ + 'Demo', + 'Tour', + ]) + expect(targets.configPath).toBe(CONFIG) + }) +}) + +type Harness = { + deps: DevWatchDeps + emit: (dir: string, fileBasename: string) => void + /** Runs the pending debounce flush and awaits its async work. */ + flush: () => Promise + hashes: Map + onSourcesChanged: ReturnType + onConfigChanged: ReturnType + watchedDirs: () => string[] + cancelledDirs: () => string[] +} + +function harness(initialHashes: Record): Harness { + const hashes = new Map(Object.entries(initialHashes)) + const listeners = new Map void>() + const cancelled: string[] = [] + let pendingFlush: (() => void) | null = null + const onSourcesChanged = vi.fn() + const onConfigChanged = vi.fn() + const deps: DevWatchDeps = { + watchDir: (dir, onEvent) => { + listeners.set(dir, onEvent) + return () => { + listeners.delete(dir) + cancelled.push(dir) + } + }, + hashSource: (filePath) => Promise.resolve(hashes.get(filePath)), + setTimeoutFn: (fn) => { + pendingFlush = fn + return () => { + pendingFlush = null + } + }, + logger: { info: () => {}, warn: () => {}, error: () => {} }, + onSourcesChanged, + onConfigChanged, + } + return { + deps, + emit: (dir, fileBasename) => listeners.get(dir)?.(fileBasename), + flush: async () => { + const run = pendingFlush + pendingFlush = null + run?.() + // The flush hashes asynchronously; let its microtasks settle. + await new Promise((resolve) => setImmediate(resolve)) + }, + hashes, + onSourcesChanged, + onConfigChanged, + watchedDirs: () => [...listeners.keys()], + cancelledDirs: () => cancelled, + } +} + +function targetsOf( + files: Record, + configPath: string | null = CONFIG +): WatchTargets { + return { + files: new Map( + Object.entries(files).map(([path, names]) => [path, new Set(names)]) + ), + configPath, + } +} + +describe('startDevWatcher', () => { + it('re-records only the videos backed by the changed file', async () => { + const h = harness({ + '/proj/demo.screenci.ts': 'a1', + '/proj/other.screenci.ts': 'b1', + [CONFIG]: 'c1', + }) + await startDevWatcher( + targetsOf({ + '/proj/demo.screenci.ts': ['Demo', 'Tour'], + '/proj/other.screenci.ts': ['Other'], + }), + h.deps + ) + h.hashes.set('/proj/demo.screenci.ts', 'a2') + h.emit('/proj', 'demo.screenci.ts') + await h.flush() + expect(h.onSourcesChanged).toHaveBeenCalledWith(['Demo', 'Tour']) + expect(h.onConfigChanged).not.toHaveBeenCalled() + }) + + it('ignores a save that does not change the content hash', async () => { + const h = harness({ '/proj/demo.screenci.ts': 'a1', [CONFIG]: 'c1' }) + await startDevWatcher( + targetsOf({ '/proj/demo.screenci.ts': ['Demo'] }), + h.deps + ) + h.emit('/proj', 'demo.screenci.ts') + await h.flush() + expect(h.onSourcesChanged).not.toHaveBeenCalled() + }) + + it('ignores events for files that are not watched', async () => { + const h = harness({ '/proj/demo.screenci.ts': 'a1', [CONFIG]: 'c1' }) + await startDevWatcher( + targetsOf({ '/proj/demo.screenci.ts': ['Demo'] }), + h.deps + ) + h.emit('/proj', 'unrelated.ts') + await h.flush() + expect(h.onSourcesChanged).not.toHaveBeenCalled() + }) + + it('routes a config change to onConfigChanged', async () => { + const h = harness({ '/proj/demo.screenci.ts': 'a1', [CONFIG]: 'c1' }) + await startDevWatcher( + targetsOf({ '/proj/demo.screenci.ts': ['Demo'] }), + h.deps + ) + h.hashes.set(CONFIG, 'c2') + h.emit('/proj', 'screenci.config.ts') + await h.flush() + expect(h.onConfigChanged).toHaveBeenCalledTimes(1) + expect(h.onSourcesChanged).not.toHaveBeenCalled() + }) + + it('debounces a burst of events into one coalesced flush', async () => { + const h = harness({ + '/proj/demo.screenci.ts': 'a1', + '/proj/other.screenci.ts': 'b1', + [CONFIG]: 'c1', + }) + await startDevWatcher( + targetsOf({ + '/proj/demo.screenci.ts': ['Demo'], + '/proj/other.screenci.ts': ['Other'], + }), + h.deps + ) + h.hashes.set('/proj/demo.screenci.ts', 'a2') + h.hashes.set('/proj/other.screenci.ts', 'b2') + h.emit('/proj', 'demo.screenci.ts') + h.emit('/proj', 'demo.screenci.ts') + h.emit('/proj', 'other.screenci.ts') + await h.flush() + expect(h.onSourcesChanged).toHaveBeenCalledTimes(1) + expect(h.onSourcesChanged).toHaveBeenCalledWith(['Demo', 'Other']) + }) + + it("suppresses the CLI's own write after refreshBaseline", async () => { + const h = harness({ '/proj/demo.screenci.ts': 'a1', [CONFIG]: 'c1' }) + const watcher = await startDevWatcher( + targetsOf({ '/proj/demo.screenci.ts': ['Demo'] }), + h.deps + ) + // Codegen writes the file, then re-baselines before the event flushes. + h.hashes.set('/proj/demo.screenci.ts', 'a2') + await watcher.refreshBaseline('/proj/demo.screenci.ts') + h.emit('/proj', 'demo.screenci.ts') + await h.flush() + expect(h.onSourcesChanged).not.toHaveBeenCalled() + // A real edit afterwards still fires. + h.hashes.set('/proj/demo.screenci.ts', 'a3') + h.emit('/proj', 'demo.screenci.ts') + await h.flush() + expect(h.onSourcesChanged).toHaveBeenCalledWith(['Demo']) + }) + + it('refreshTargets adds and removes directory watchers', async () => { + const h = harness({ + '/proj/demo.screenci.ts': 'a1', + '/elsewhere/new.screenci.ts': 'n1', + [CONFIG]: 'c1', + }) + const watcher = await startDevWatcher( + targetsOf({ '/proj/demo.screenci.ts': ['Demo'] }), + h.deps + ) + expect(h.watchedDirs()).toEqual(['/proj']) + await watcher.refreshTargets( + targetsOf({ '/elsewhere/new.screenci.ts': ['New'] }) + ) + expect(h.watchedDirs().sort()).toEqual(['/elsewhere', '/proj']) + h.hashes.set('/elsewhere/new.screenci.ts', 'n2') + h.emit('/elsewhere', 'new.screenci.ts') + await h.flush() + expect(h.onSourcesChanged).toHaveBeenCalledWith(['New']) + }) + + it('stop cancels watchers and the pending flush', async () => { + const h = harness({ '/proj/demo.screenci.ts': 'a1', [CONFIG]: 'c1' }) + const watcher = await startDevWatcher( + targetsOf({ '/proj/demo.screenci.ts': ['Demo'] }), + h.deps + ) + h.hashes.set('/proj/demo.screenci.ts', 'a2') + h.emit('/proj', 'demo.screenci.ts') + watcher.stop() + await h.flush() + expect(h.onSourcesChanged).not.toHaveBeenCalled() + expect(h.cancelledDirs()).toContain('/proj') + }) + + it('works without a config path', async () => { + const h = harness({ '/proj/demo.screenci.ts': 'a1' }) + await startDevWatcher( + targetsOf({ '/proj/demo.screenci.ts': ['Demo'] }, null), + h.deps + ) + h.hashes.set('/proj/demo.screenci.ts', 'a2') + h.emit('/proj', 'demo.screenci.ts') + await h.flush() + expect(h.onSourcesChanged).toHaveBeenCalledWith(['Demo']) + }) +}) diff --git a/src/devWatch.ts b/src/devWatch.ts new file mode 100644 index 0000000..257e59d --- /dev/null +++ b/src/devWatch.ts @@ -0,0 +1,225 @@ +/** + * Source-file watcher behind `screenci dev`: watches the test sources backing + * the session's managed videos (plus screenci.config.ts) and raises a + * machine-local preview re-record when one of them changes. + * + * The video-to-source map reuses the same recording metadata the startup + * handshake reads (`sourceFilePath` per kept recording, `--grep` filtering). + * Watching is directory-level (non-recursive) and filtered by basename, so + * editor atomic saves (write temp file, rename over the original) are seen as + * plain changes. Every flush re-hashes the changed files and compares against + * a per-file baseline: a save that does not change the content (or the CLI's + * own codegen write, whose baseline is refreshed by the caller) triggers + * nothing. + * + * All side effects (directory watching, hashing, timers) are injected so the + * decision logic is unit-testable without a real file system. + */ +import { dirname, join } from 'path' +import { watch } from 'fs' +import { hashSourceFile } from './recordingFreshness.js' +import type { DevListenLogger } from './devListen.js' +import type { KeptRecording } from './devStartup.js' + +export const DEV_WATCH_DEBOUNCE_MS = 300 + +/** The files to watch: absolute source path to the video names it backs. */ +export type WatchTargets = { + files: Map> + /** The resolved screenci.config.ts path; changes re-record everything. */ + configPath: string | null +} + +/** + * Builds the watch targets from kept recording data: every managed + * (grep-matched) video's `sourceFilePath`, plus the config file. + */ +export function buildWatchTargets( + recordings: KeptRecording[], + matches: (videoName: string) => boolean, + configPath: string | null +): WatchTargets { + const files = new Map>() + for (const kept of recordings) { + const videoName = kept.data.metadata?.videoName + const sourceFile = kept.data.metadata?.sourceFilePath + if (videoName === undefined || sourceFile === undefined) continue + if (!matches(videoName)) continue + const names = files.get(sourceFile) ?? new Set() + names.add(videoName) + files.set(sourceFile, names) + } + return { files, configPath } +} + +export type DevWatchDeps = { + /** + * Watches a directory (non-recursive) and reports the basename of each + * changed entry. Returns a cancel function. Defaults to `fs.watch`. + */ + watchDir?: ( + dir: string, + onEvent: (fileBasename: string) => void + ) => () => void + /** Hashes a source file; undefined when unreadable. Defaults to SHA-256. */ + hashSource?: (filePath: string) => Promise + /** Debounce timer, injectable for tests. Returns a cancel function. */ + setTimeoutFn?: (fn: () => void, ms: number) => () => void + debounceMs?: number + logger: DevListenLogger + /** Called with the video names affected by changed source files. */ + onSourcesChanged: (videoNames: string[]) => void + /** Called when the config file changed. */ + onConfigChanged: () => void +} + +export type DevWatcherController = { + stop: () => void + /** Replaces the watched file set (after a record learned new sources). */ + refreshTargets: (targets: WatchTargets) => Promise + /** + * Re-baselines one file after the CLI's own write (codegen, editId stamps) + * so the watcher does not re-record on its own output. + */ + refreshBaseline: (filePath: string) => Promise +} + +function defaultWatchDir( + dir: string, + onEvent: (fileBasename: string) => void +): () => void { + // `rename` and `change` are treated identically: atomic saves surface as + // renames. Errors (deleted directory, network FS) stop that watcher only. + const watcher = watch(dir, (_eventType, fileName) => { + if (typeof fileName === 'string' && fileName.length > 0) { + onEvent(fileName) + } + }) + watcher.on('error', () => { + watcher.close() + }) + return () => watcher.close() +} + +function defaultSetTimeout(fn: () => void, ms: number): () => void { + const handle = setTimeout(fn, ms) + return () => clearTimeout(handle) +} + +/** + * Starts watching the given targets. Watches each parent directory once, + * filters events by the watched basenames, debounces, then re-hashes each + * candidate file and reports only real content changes. + */ +export async function startDevWatcher( + targets: WatchTargets, + deps: DevWatchDeps +): Promise { + const watchDir = deps.watchDir ?? defaultWatchDir + const hashSource = deps.hashSource ?? hashSourceFile + const setTimeoutFn = deps.setTimeoutFn ?? defaultSetTimeout + const debounceMs = deps.debounceMs ?? DEV_WATCH_DEBOUNCE_MS + + let current: WatchTargets = targets + let stopped = false + const baselines = new Map() + const dirWatchers = new Map void>() + const pending = new Set() + let cancelFlush: (() => void) | null = null + + const watchedPaths = (): string[] => [ + ...current.files.keys(), + ...(current.configPath !== null ? [current.configPath] : []), + ] + + const flush = async (): Promise => { + cancelFlush = null + const changed = [...pending] + pending.clear() + const videoNames = new Set() + let configChanged = false + for (const filePath of changed) { + const hash = await hashSource(filePath) + if (hash === baselines.get(filePath)) continue // no-op save + baselines.set(filePath, hash) + if (filePath === current.configPath) { + configChanged = true + continue + } + for (const name of current.files.get(filePath) ?? []) { + videoNames.add(name) + } + } + if (stopped) return + if (configChanged) { + deps.onConfigChanged() + return + } + if (videoNames.size > 0) { + deps.onSourcesChanged([...videoNames]) + } + } + + const onDirEvent = (dir: string, fileBasename: string): void => { + if (stopped) return + const filePath = join(dir, fileBasename) + if (!watchedPaths().includes(filePath)) return + pending.add(filePath) + cancelFlush?.() + cancelFlush = setTimeoutFn(() => { + void flush() + }, debounceMs) + } + + const syncDirWatchers = (): void => { + const wantedDirs = new Set(watchedPaths().map((path) => dirname(path))) + for (const [dir, cancel] of dirWatchers) { + if (!wantedDirs.has(dir)) { + cancel() + dirWatchers.delete(dir) + } + } + for (const dir of wantedDirs) { + if (dirWatchers.has(dir)) continue + try { + dirWatchers.set( + dir, + watchDir(dir, (fileBasename) => onDirEvent(dir, fileBasename)) + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + deps.logger.warn(`Cannot watch ${dir}: ${message}`) + } + } + } + + const captureBaselines = async (): Promise => { + for (const filePath of watchedPaths()) { + if (!baselines.has(filePath)) { + baselines.set(filePath, await hashSource(filePath)) + } + } + } + + syncDirWatchers() + await captureBaselines() + + return { + stop: () => { + stopped = true + cancelFlush?.() + cancelFlush = null + for (const cancel of dirWatchers.values()) cancel() + dirWatchers.clear() + }, + refreshTargets: async (next) => { + current = next + syncDirWatchers() + await captureBaselines() + }, + refreshBaseline: async (filePath) => { + baselines.set(filePath, await hashSource(filePath)) + pending.delete(filePath) + }, + } +} diff --git a/src/editableSnapshot.spec.ts b/src/editableSnapshot.spec.ts index d91e31b..c94b437 100644 --- a/src/editableSnapshot.spec.ts +++ b/src/editableSnapshot.spec.ts @@ -51,6 +51,72 @@ describe('splitTimelineEditsByVideo', () => { expect(codify.broken).toBeUndefined() expect(renames.demo).toEqual([{ editId: 'click1', newEditId: 'save' }]) }) + + it('splits options and narration records into their own buckets', () => { + const { studioOptions, narrationEdits } = splitTimelineEditsByVideo({ + demo: { + version: 4, + edits: [ + { + type: 'optionsEdit', + id: 'options|renderOptions', + method: 'renderOptions', + values: { fps: 60 }, + }, + { + type: 'optionsEdit', + id: 'options|recordOptions', + method: 'recordOptions', + values: { headless: true }, + }, + { + type: 'narrationEdit', + id: 'narration|intro|default', + cueName: 'intro', + lang: 'default', + value: 'Hi', + }, + { + type: 'narrationEdit', + id: 'narration|intro|fi', + cueName: 'intro', + lang: 'fi', + value: { cue: 'Moi', volume: 0.5 }, + }, + ], + }, + }) + expect(studioOptions.demo).toEqual({ + renderOptions: { fps: 60 }, + recordOptions: { headless: true }, + }) + expect(narrationEdits.demo?.map((edit) => edit.id)).toEqual([ + 'narration|intro|default', + 'narration|intro|fi', + ]) + }) + + it('ignores malformed options and narration records', () => { + const { studioOptions, narrationEdits } = splitTimelineEditsByVideo({ + demo: { + version: 4, + edits: [ + { type: 'optionsEdit', id: 'x', method: 'other', values: {} }, + { type: 'optionsEdit', id: 'y', method: 'renderOptions' }, + { + type: 'narrationEdit', + id: 'z', + cueName: 'intro', + lang: 'default', + value: { volume: 0.5 }, + }, + { type: 'narrationEdit', id: 'w', cueName: 'intro', value: 'Hi' }, + ], + }, + }) + expect(studioOptions).toEqual({}) + expect(narrationEdits).toEqual({}) + }) }) describe('collectEditableFromRecordings', () => { diff --git a/src/editableSnapshot.ts b/src/editableSnapshot.ts index 3fec59d..5f2f950 100644 --- a/src/editableSnapshot.ts +++ b/src/editableSnapshot.ts @@ -9,10 +9,13 @@ import { join } from 'path' import { stableEditableKey } from './editableDescriptor.js' import type { CodifyEdit, + NarrationEdit, + OptionsEdit, OverlayDeclEdit, ParamEdit, RenameEdit, } from './timelineEdits.js' +import { OPTIONS_EDIT_METHODS } from './timelineEdits.js' /** Param-edit values per video, in the stable-key entry shape the snapshot * comparisons work with. */ @@ -35,6 +38,18 @@ export type RenamesByVideo = Record< /** Overlay declaration placement edits per video (codified by sync). */ export type OverlayDeclEditsByVideo = Record +/** Render/record option snapshots per video (codified into builder calls). */ +export type StudioOptionsByVideo = Record< + string, + { + renderOptions?: Record + recordOptions?: Record + } +> + +/** Narration cue value edits per video (codified into the declaration). */ +export type NarrationEditsByVideo = Record + /** * Splits fetched unified timeline-edits docs (keyed by video name) into the * shapes the status report and sync prompt consume. Records that do not look @@ -51,12 +66,16 @@ export function splitTimelineEditsByVideo( removedCodify: CodifyEditsByVideo renames: RenamesByVideo overlayDecls: OverlayDeclEditsByVideo + studioOptions: StudioOptionsByVideo + narrationEdits: NarrationEditsByVideo } { const overrides: EditableOverridesByVideo = {} const codify: CodifyEditsByVideo = {} const removedCodify: CodifyEditsByVideo = {} const renames: RenamesByVideo = {} const overlayDecls: OverlayDeclEditsByVideo = {} + const studioOptions: StudioOptionsByVideo = {} + const narrationEdits: NarrationEditsByVideo = {} const CODIFY_TYPES = new Set([ 'mediaEdit', 'zoomEdit', @@ -108,10 +127,44 @@ export function splitTimelineEditsByVideo( continue } ;(overlayDecls[videoName] ??= []).push(decl) + } else if (record.type === 'optionsEdit') { + const options = edit as OptionsEdit + if ( + !OPTIONS_EDIT_METHODS.includes(options.method) || + typeof options.values !== 'object' || + options.values === null + ) { + continue + } + ;(studioOptions[videoName] ??= {})[options.method] = options.values + } else if (record.type === 'narrationEdit') { + const narration = edit as NarrationEdit + const value = narration.value as unknown + const validValue = + typeof value === 'string' || + (typeof value === 'object' && + value !== null && + typeof (value as { cue?: unknown }).cue === 'string') + if ( + typeof narration.cueName !== 'string' || + typeof narration.lang !== 'string' || + !validValue + ) { + continue + } + ;(narrationEdits[videoName] ??= []).push(narration) } } } - return { overrides, codify, removedCodify, renames, overlayDecls } + return { + overrides, + codify, + removedCodify, + renames, + overlayDecls, + studioOptions, + narrationEdits, + } } /** One editable action as recorded by the previous run. */ diff --git a/src/timelineEdits.ts b/src/timelineEdits.ts index bfa28ef..37e960e 100644 --- a/src/timelineEdits.ts +++ b/src/timelineEdits.ts @@ -25,9 +25,16 @@ * in gaps (after `fromEditId` and after `untilEditId`). * - `gapPointEdit`: an instant `moveNarration`/`resizeRecording`/`setBackground` * point in the gap after an action. + * - `optionsEdit`: a full snapshot of the editor's render or record option + * values, merged into the video's `.renderOptions({...})` / + * `.recordOptions({...})` builder call (added when missing). + * - `narrationEdit`: a narration cue value change, merged into the + * `video.narration({...})` declaration (added when missing; the declaration + * is converted to the language-major form when a non-default language is + * edited). */ -export const TIMELINE_EDITS_VERSION = 3 +export const TIMELINE_EDITS_VERSION = 4 // ─── Edit records ──────────────────────────────────────────────────────────── @@ -167,10 +174,62 @@ export type OverlayDeclEdit = { disabled?: boolean } +export const OPTIONS_EDIT_METHODS = ['renderOptions', 'recordOptions'] as const +export type OptionsEditMethod = (typeof OPTIONS_EDIT_METHODS)[number] + +/** + * A full snapshot of the editor's render or record option values for a video. + * Merged (deep, idempotent) into the existing `.({...})` object + * literal of the video builder chain, or appended as a new `.({...})` + * call when the section is missing. The id is `options|` so repeated + * sends coalesce last-write-wins per method. + */ +export type OptionsEdit = { + type: 'optionsEdit' + id: string + method: OptionsEditMethod + values: Record +} + +/** + * The value of one narration cue: a plain string, or an object carrying the + * cue text plus per-cue metadata (voice override, volume). + */ +export type NarrationCueValue = string | { cue: string; [key: string]: unknown } + +/** + * A narration cue value change made in the web editor, applied to the + * `video.narration(...)` declaration argument. `lang` is a language code or + * `'default'` for the shared value. When the declaration is content-major and + * a non-default language is edited, the argument is rewritten to the + * language-major form (existing values move under `default`). The id is + * `narration||` so repeated sends coalesce per cue and + * language. + */ +export type NarrationEdit = { + type: 'narrationEdit' + id: string + cueName: string + lang: string + /** + * True when `lang` is the video's default language: the edit targets the + * shared value (content-major object or `default` sub-object) unless the + * declaration carries an explicit `[lang]` sub-object. + */ + isDefault?: boolean + value: NarrationCueValue +} + /** Codify-only records: placed into code by `screenci sync`, never at runtime. */ export type CodifyEdit = MediaEdit | ZoomEdit | GapSpanEdit | GapPointEdit -export type EditRecord = ParamEdit | RenameEdit | CodifyEdit | OverlayDeclEdit +export type EditRecord = + | ParamEdit + | RenameEdit + | CodifyEdit + | OverlayDeclEdit + | OptionsEdit + | NarrationEdit export type TimelineEditsDoc = { version: number @@ -193,3 +252,13 @@ export function overlayIdFor(name: string, ordinal: number): string { export function overlayDeclIdFor(name: string): string { return `overlaydecl-${name}` } + +/** Stable id of the options snapshot edit for a builder method. */ +export function optionsEditIdFor(method: OptionsEditMethod): string { + return `options|${method}` +} + +/** Stable id of the narration value edit for a cue name and language. */ +export function narrationEditIdFor(cueName: string, lang: string): string { + return `narration|${cueName}|${lang}` +}