Remove code-sync (sync command, --watch, implicit drains); link the video overview page

- Drop the sync command, preview --watch/--no-watch/--no-sync, --record-kill-window, and the queued-edit drains before test/export/preview. Editor edits now live in the web editor and are not written back to sources.
- Delete codeSync, applyCodegen, editorOptionsSync, devWatch, codegenFailureLog and their specs; trim devListen to register/sync-state/deregister.
- preview is always one-shot; generated CI workflows run plain screenci preview.
- CLI links point at the video overview page: preview prints /project/<pid>/video/<vid>, a single-video export prints ...?export=<recordId>, multi-video runs keep the run pages. Held-video notices deep-link ?editor.
- Rewrite docs, skill files, and the narration guidance: narrate the flow rather than each click, use the product's own vocabulary, and fill forms with fictitious example data (Emma Carter, emma@aperturebio.com).
This commit is contained in:
Olli Paloviita
2026-09-01 23:32:36 +03:00
parent bf6ee99256
commit ce688f03ac
45 changed files with 366 additions and 9610 deletions
+18 -992
View File
File diff suppressed because it is too large Load Diff
-61
View File
@@ -1,61 +0,0 @@
import { describe, expect, it } from 'vitest'
import { resolveSingleEditVideo } from './cli'
const suggest = (name: string) => `screenci edit "${name}"`
describe('resolveSingleEditVideo', () => {
it('resolves the only video of a project without a pattern', () => {
expect(resolveSingleEditVideo(['Login'], undefined, suggest)).toEqual({
ok: true,
videoName: 'Login',
})
})
it('resolves a pattern matching exactly one video', () => {
const result = resolveSingleEditVideo(['Login', 'Signup'], 'Sign', suggest)
expect(result).toEqual({ ok: true, videoName: 'Signup' })
})
it('errors with the available titles when nothing matches', () => {
const result = resolveSingleEditVideo(['Login', 'Signup'], 'Nope', suggest)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.message).toContain('No video matches "Nope"')
expect(result.message).toContain('- Login')
expect(result.message).toContain('- Signup')
}
})
it('errors listing the matches when the pattern matches several videos', () => {
const result = resolveSingleEditVideo(
['Login flow', 'Login error', 'Signup'],
'Login',
suggest
)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.message).toContain('one video at a time')
expect(result.message).toContain('- Login flow')
expect(result.message).toContain('- Login error')
expect(result.message).not.toContain('- Signup')
expect(result.message).toContain('screenci edit "Login flow"')
}
})
it('errors asking to pick when the project has several videos and no pattern', () => {
const result = resolveSingleEditVideo(['A', 'B'], undefined, suggest)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.message).toContain('one video at a time')
expect(result.message).toContain('screenci edit "A"')
}
})
it('errors when the project has no videos at all', () => {
const result = resolveSingleEditVideo([], 'Login', suggest)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.message).toContain('No videos found')
}
})
})
+27 -2
View File
@@ -880,6 +880,7 @@ describe('CLI', () => {
recordId: expect.any(String),
hadFailures: false,
uploadedVideoNames: expect.any(Array),
uploadedVideos: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -1573,13 +1574,26 @@ describe('CLI', () => {
expect(result.uploadedVideoNames).toEqual(['Demo'])
})
it('formats preview URLs', async () => {
it('formats video overview URLs', async () => {
const { formatPreviewUrl } = await import('./cli')
expect(
formatPreviewUrl('https://app.screenci.test', 'project_1', 'video_2')
).toBe('https://app.screenci.test/project/project_1/video/video_2')
})
it('formats single-video export URLs with the run preselected', async () => {
const { formatVideoExportUrl } = await import('./cli')
expect(
formatVideoExportUrl(
'https://app.screenci.test',
'project_1',
'video_2',
'rec_3'
)
).toBe(
'https://app.screenci.test/project/project_1/video/video_2/preview'
'https://app.screenci.test/project/project_1/video/video_2?export=rec_3'
)
})
@@ -1895,6 +1909,7 @@ describe('CLI', () => {
recordId: null,
hadFailures: false,
uploadedVideoNames: expect.any(Array),
uploadedVideos: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -1935,6 +1950,7 @@ describe('CLI', () => {
recordId: expect.any(String),
hadFailures: true,
uploadedVideoNames: expect.any(Array),
uploadedVideos: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -2030,6 +2046,7 @@ describe('CLI', () => {
recordId: expect.any(String),
hadFailures: false,
uploadedVideoNames: expect.any(Array),
uploadedVideos: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -2136,6 +2153,7 @@ describe('CLI', () => {
recordId: expect.any(String),
hadFailures: true,
uploadedVideoNames: expect.any(Array),
uploadedVideos: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -2346,6 +2364,7 @@ describe('CLI', () => {
recordId: expect.any(String),
hadFailures: false,
uploadedVideoNames: expect.any(Array),
uploadedVideos: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -2556,6 +2575,7 @@ describe('CLI', () => {
recordId: expect.any(String),
hadFailures: true,
uploadedVideoNames: expect.any(Array),
uploadedVideos: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -2613,6 +2633,7 @@ describe('CLI', () => {
recordId: null,
hadFailures: true,
uploadedVideoNames: expect.any(Array),
uploadedVideos: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -2773,6 +2794,7 @@ describe('CLI', () => {
recordId: expect.any(String),
hadFailures: false,
uploadedVideoNames: expect.any(Array),
uploadedVideos: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -2853,6 +2875,7 @@ describe('CLI', () => {
recordId: expect.any(String),
hadFailures: false,
uploadedVideoNames: expect.any(Array),
uploadedVideos: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
@@ -2959,6 +2982,8 @@ describe('CLI', () => {
recordId: expect.any(String),
hadFailures: false,
uploadedVideoNames: expect.any(Array),
uploadedVideos: expect.any(Array),
uploadedVideos: expect.any(Array),
studioNotices: [],
elevenLabsKeyMissingVideos: [],
notices: [],
+5 -7
View File
@@ -516,7 +516,7 @@ describe('CLI', () => {
expect(islandPkg['name']).toBe('my-project')
expect(islandPkg['scripts']).toMatchObject({
test: 'screenci test',
edit: 'screenci edit',
preview: 'screenci preview',
export: 'screenci export',
})
expect(islandPkg['scripts']).not.toHaveProperty('screenci')
@@ -914,9 +914,7 @@ describe('CLI', () => {
expect(workflowCall?.[1]).toContain(
'run: pnpm exec playwright install --only-shell chromium'
)
expect(workflowCall?.[1]).toContain(
'pnpm exec screenci preview --no-sync'
)
expect(workflowCall?.[1]).toContain('pnpm exec screenci preview')
// Export stays available as a commented-out alternative.
expect(workflowCall?.[1]).toContain(
'# pnpm exec screenci export --no-wait'
@@ -929,7 +927,7 @@ describe('CLI', () => {
// Targeted recordings: optional `grep` input forwarded to record.
expect(workflowCall?.[1]).toContain('SCREENCI_GREP: ${{ inputs.grep }}')
expect(workflowCall?.[1]).toContain(
'pnpm exec screenci preview --no-sync --grep "$SCREENCI_GREP"'
'pnpm exec screenci preview --grep "$SCREENCI_GREP"'
)
expect(workflowCall?.[1]).toMatch(/workflow_dispatch:\s*\n\s*inputs:/)
})
@@ -1091,9 +1089,9 @@ describe('CLI', () => {
expect(workflowCall?.[1]).toContain(
'run: yarn playwright install --only-shell chromium'
)
expect(workflowCall?.[1]).toContain('yarn screenci preview --no-sync')
expect(workflowCall?.[1]).toContain('yarn screenci preview')
expect(workflowCall?.[1]).toContain(
'yarn screenci preview --no-sync --grep "$SCREENCI_GREP"'
'yarn screenci preview --grep "$SCREENCI_GREP"'
)
})
-116
View File
@@ -577,42 +577,6 @@ describe('CLI', () => {
expect(processExitSpy).toHaveBeenCalledWith(1)
})
// --no-sync (CI runners keep their checkout read-only) must parse as a
// known preview option; the command still exits deterministically on the
// expired trial session.
it('accepts --no-sync for the preview command', async () => {
process.argv = ['node', 'cli.js', 'preview', '--no-sync']
delete process.env.SCREENCI_SECRET
mockFetch.mockImplementation(async (input: string | URL) => {
if (String(input).endsWith('/cli/anon-session-status')) {
return {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({ status: 'expired' }),
text: vi.fn().mockResolvedValue(''),
}
}
return {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({}),
text: vi.fn().mockResolvedValue(''),
}
})
const { main } = await import('./cli')
await expect(main()).rejects.toThrow('process.exit called')
expect(loggerErrorSpy).not.toHaveBeenCalledWith(
expect.stringContaining('unknown option')
)
expect(loggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('trial has expired')
)
expect(processExitSpy).toHaveBeenCalledWith(1)
})
it('should show global help with --help', async () => {
process.argv = ['node', 'cli.js', '--help']
const stdoutSpy = vi
@@ -1199,84 +1163,4 @@ describe('CLI', () => {
)
})
})
describe('createQueuedEditsDrainer', () => {
const drainerConfig = {
apiUrl: 'http://localhost:8787',
credential: { header: 'X-ScreenCI-Secret', value: 'org-secret' },
devToken: 'dev-token',
projectName: 'demo',
machineName: 'laptop',
}
function makeDrainerDeps() {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ codegenRequests: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
return {
fetchMock,
deps: {
fetchFn: fetchMock as unknown as typeof fetch,
sleep: vi.fn(async () => {}),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
runRecord: vi.fn(async () => {}),
},
}
}
it('drains queued edits when sync is enabled', async () => {
const { createQueuedEditsDrainer } = await import('./cli')
const { fetchMock, deps } = makeDrainerDeps()
const drain = createQueuedEditsDrainer({
syncEnabled: true,
config: drainerConfig,
deps,
listenerId: 'lst_1',
})
await drain()
expect(fetchMock).toHaveBeenCalled()
})
// preview --no-sync: CI checkouts stay read-only, so the drainer must not
// even reach the server for queued edits.
it('is a no-op when sync is disabled', async () => {
const { createQueuedEditsDrainer } = await import('./cli')
const { fetchMock, deps } = makeDrainerDeps()
const drain = createQueuedEditsDrainer({
syncEnabled: false,
config: drainerConfig,
deps,
listenerId: 'lst_1',
})
await drain()
expect(fetchMock).not.toHaveBeenCalled()
expect(deps.logger.warn).not.toHaveBeenCalled()
})
it('warns instead of throwing when the drain fails', async () => {
const { createQueuedEditsDrainer } = await import('./cli')
const { fetchMock, deps } = makeDrainerDeps()
fetchMock.mockRejectedValueOnce(new Error('network down'))
const drain = createQueuedEditsDrainer({
syncEnabled: true,
config: drainerConfig,
deps,
listenerId: 'lst_1',
})
await expect(drain()).resolves.toBeUndefined()
expect(deps.logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Could not sync queued editor edits')
)
})
})
})
+83 -840
View File
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -41,12 +41,10 @@ The brief walks the agent through these steps:
driven with visible clicks.
3. **Verify, then preview.** It runs `npx screenci test` until the script is
green, then `npx screenci preview "<video title>"` to record the free live
preview and hand you the preview link, so you review the video before
preview and hand you the video link, so you review the video before
anything renders. Every edit you make in the [Editor](/docs/editor)
(narration, overlays, cuts, timings) is written back into the script,
ready for the agent or you to build on: live while a
`screenci preview --watch` session is connected, and otherwise queued and
applied by the next `screenci sync`, `test`, or `preview`. No account is needed: without a
(narration, overlays, cuts, timings) applies to the preview and later
exports right away. No account is needed: without a
`SCREENCI_SECRET` it runs under a local, anonymous trial session, and
signing up in the editor claims the trial and upgrades a running session
automatically.
+5 -7
View File
@@ -2,9 +2,8 @@
`screenci preview` needs no account or `SCREENCI_SECRET` to try. Without one, a
recording uploads under a local, anonymous trial session: the CLI records the
live preview and prints the preview link, and every edit you make in the
browser is written back into your script (live with `screenci preview --watch`,
otherwise queued and applied by the next `screenci` command). You sign up afterward to
live preview and prints the video link, and every edit you make in the
browser applies to the preview right away. You sign up afterward to
keep the trial and export it. Recording an anonymous trial agrees to the
[Terms](https://screenci.com/legal/tos), which the CLI prints before it starts.
@@ -16,9 +15,8 @@ An anonymous trial is a full preview of ScreenCI's editing workflow:
live preview as often as you like for the trial's lifetime; nothing renders
and nothing is billed.
- **The full web editor.** The editor is fully editable: narration, overlays,
timeline cuts, camera, and render options, with every change written back
into your `.screenci.ts` sources (live while a `screenci preview --watch`
session is connected, otherwise on the next `screenci` command).
timeline cuts, camera, and render options; every change applies to the
preview and later exports right away.
- **Expressive narration** (style prompts and tone control) in the preview.
- **Up to 3 narration languages at once** in a recording, a taste of the
Business tier's unlimited languages.
@@ -50,7 +48,7 @@ renders and downloads the finished videos.
## What's next
- [Editor](/docs/editor) for how browser edits sync back into your script.
- [Editor](/docs/editor) for how browser edits shape your video.
- [Languages](/docs/guides/languages) to learn how multi-language rendering
works once you are signed up.
- [Narration](/docs/guides/narration) for expressive voices, style prompts,
+1 -1
View File
@@ -22,7 +22,7 @@ video('Edit profile', async ({ page }) => {
await page.goto('/settings/profile')
await autoZoom(async () => {
await page.getByLabel('Name').fill('Jane Doe')
await page.getByLabel('Name').fill('Emma Carter')
await page.getByLabel('Email').fill('jane@screenci.com')
await page.getByRole('button', { name: 'Save' }).click()
})
+2 -6
View File
@@ -22,15 +22,11 @@ left untouched on re-run.
The workflow runs on pushes to `main` and on
[`workflow_dispatch`](https://docs.github.com/en/actions/using-workflows/manually-running-a-workflow),
installs Node.js 24 with dependency caching, installs the Playwright Chromium
Headless Shell, and runs `screenci preview --no-sync`. It mirrors
Headless Shell, and runs `screenci preview`. It mirrors
[Playwright CI](https://playwright.dev/docs/ci). Use `push` to keep previews
current automatically, or `workflow_dispatch` for a manual, targeted run.
`preview --no-sync` re-records every requested video and updates the live
previews. `--no-sync` keeps the CI checkout read-only: queued editor edits are
not pulled into the sources there (edits drained on a throwaway CI runner
would be lost); they stay queued for your next local `preview` or `sync`, and
the step logs a short note when edits are pending.
`preview` re-records every requested video and updates the live previews.
Prefer final rendered videos instead of live previews? The generated workflow
contains a commented-out alternative that swaps the record step for
+34 -147
View File
@@ -12,16 +12,16 @@ commands from inside your `screenci/` project directory. Most commands resolve
## Command overview
| Command | Purpose |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `screenci init [name]` | Scaffold a ScreenCI project |
| `screenci test [playwrightArgs]` | Run `.screenci.ts` files locally without final recording |
| `screenci preview [pattern]` | Record live previews and print their links; with `--watch`, connect this machine as one video's code-sync bridge |
| `screenci export [patterns...]` | Produce finished videos: re-record every requested video, render, and download |
| `screenci info` | Print the last record run's URLs and render status |
| `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 |
| Command | Purpose |
| --------------------------------- | ------------------------------------------------------------------------------ |
| `screenci init [name]` | Scaffold a ScreenCI project |
| `screenci test [playwrightArgs]` | Run `.screenci.ts` files locally without final recording |
| `screenci preview [pattern]` | Record live previews and print their links |
| `screenci export [patterns...]` | Produce finished videos: re-record every requested video, render, and download |
| `screenci info` | Print the last record run's URLs and render status |
| `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 |
## `screenci init`
@@ -274,8 +274,11 @@ Positional arguments are title patterns (multiple are OR-combined, like
every language. Downloads are named `<title>.<lang>.mp4`.
The command polls render status every 5 seconds for up to 30 minutes
and exits `0` only when every requested video rendered and downloaded. After a
run it prints the run's page in the app (`<app>/export/<recordId>`), which the
project page lists under **Exports**.
run it prints the video's page in the app: a single-video run links that
video's overview page with the run preselected
(`<app>/project/<projectId>/video/<videoId>?export=<recordId>`), a run with
several videos links the run's page (`<app>/export/<recordId>`), which links
each video's page.
### `--languages <langs>`
@@ -321,15 +324,6 @@ are visible on the results page, not in the exit code. Without this flag,
Deprecated no-op, kept so existing scripts do not break: `export` always
re-records every requested video.
### `--no-sync`
Deprecated no-op: `export` never applies queued browser edits. An export
renders exactly what your sources say. When edits are still queued in the
editor, `export` prints a warning naming the count and proceeds without them;
apply them first with `screenci sync` or `screenci preview`. (On
`screenci preview` the flag is real: there it disables the edit sync, see
[`preview --no-sync`](#screenci-preview-pattern).)
Behavior:
- enables recording timing for every requested video
@@ -353,25 +347,13 @@ Important restriction:
## `screenci preview [pattern]`
Records live previews and prints their links. One-shot `preview` (no
`--watch`) syncs any queued web edits into your `.screenci.ts` sources
(skipped with `--no-sync`, see the option below),
re-records and uploads the live preview of every matched video that is stale
(all managed videos when no pattern is given), prints the link, and exits.
When exactly one video was recorded it prints that video's preview page
(`<app>/project/<projectId>/video/<videoId>/preview`); when several were
recorded it prints the run listing page (`<app>/preview/<recordId>`), which
links each video's preview page. Edits made in the web editor while no
machine is connected queue server-side and are written into your sources by
the next `screenci sync`, `test`, or `preview` (`export` never applies edits;
it warns about queued ones and renders your sources as they are).
With `--watch`, the command instead stays running as the live code-sync
bridge for exactly one video: every web edit is written back into your
sources within seconds, and the editor can trigger a local record of the
video on demand. It keeps polling the service for edit and record requests
until you stop it with Ctrl-C. See [Editor](/docs/editor) for the editing
model.
Records live previews and prints their links: it re-records and uploads the
live preview of every matched video that is stale (all managed videos when no
pattern is given), prints the link, and exits. When exactly one video was
recorded it prints that video's overview page
(`<app>/project/<projectId>/video/<videoId>`); when several were recorded it
prints the run listing page (`<app>/preview/<recordId>`), which links each
video's page.
```bash
screenci preview
@@ -379,10 +361,7 @@ screenci preview
Filter videos by passing a title pattern, the same way
`playwright test <pattern>` does; `--grep` does the same and takes precedence
when both are given. With `--watch`, the pattern must match exactly one
video: a pattern that matches zero or several videos makes the command exit
with an error ("screenci preview --watch manages one video at a time") that
lists the candidate titles.
when both are given.
```bash
screenci preview "Auto-zoom"
@@ -393,13 +372,11 @@ recording started" notice to the backend, so an open web preview page shows a
live "Recording preview..." indicator and refreshes automatically when the
new preview lands.
A one-shot run prints one line per phase: the sync summary, the record
announcement, the upload results, one line per video whose editor-uploaded
media (overlays, audio, narration audio, cloned voices) shapes the render,
and the preview link:
A run prints one line per phase: the record announcement, the upload results,
one line per video whose editor-uploaded media (overlays, audio, narration
audio, cloned voices) shapes the render, and the video link:
```
Synced 3 editor edits into your sources.
Recording 1 video: Search for Alfa Forni Moderno 2
...
✔ Uploaded "Search for Alfa Forni Moderno 2 [en]"
@@ -407,17 +384,11 @@ Recording 1 video: Search for Alfa Forni Moderno 2
Editor-uploaded media for "Search for Alfa Forni Moderno 2" applies at render time; recordings always run from code.
Open the live preview for "Search for Alfa Forni Moderno 2" at:
<app>/project/<projectId>/video/<videoId>/preview
<app>/project/<projectId>/video/<videoId>
```
Per-edit sync details are not printed by the one-shot run (the editor's
pending list shows each edit and who queued it); a live `--watch` session
logs every edit as it is written. Edits an apply skips or fails always log.
Options:
- `-w, --watch`: stay connected as the live bridge (see above) instead of the
one-shot default.
- `-c, --config <path>`: path to the ScreenCI config file.
- `--token <token>`: personal editor token. Defaults to `SCREENCI_EDIT_TOKEN` from
your project env file; when neither is set and a `SCREENCI_SECRET` is
@@ -428,17 +399,6 @@ Options:
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.
- `--no-watch`: stay connected like `--watch`, but without the source-file
watcher (see below).
- `--no-sync`: do not pull queued browser edits into the sources. Meant for
CI runners, whose checkouts must stay read-only (the generated GitHub
workflow uses it, see [CI Setup](/docs/ci-setup)); edits stay queued
for your next local `preview` or `sync`, and the run prints a short note
when edits are pending.
- `-v, --verbose`: verbose output.
Startup check: without a pattern or `--grep`, `screenci preview` records every
@@ -448,99 +408,26 @@ recording's `data.json` on disk after upload, along with a hash of the test
source file, and on startup 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.
Edits queued in the editor for a video name that is no longer declared in
your code (usually a rename) are marked failed in the editor; the CLI only
logs about them when your own `--grep` (or pattern) matches that name.
Editor codegen: while connected with `--watch`, 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). Applying an
edit never re-records on its own: render-time edits re-baseline the kept
recording's source hash so it stays fresh, while a capture-affecting edit
leaves the recording stale and takes effect at the next preview or export.
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 with `--watch`, `screenci preview` 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`.
into the source and is re-recorded as a preview (no render).
Setup (with an account): none beyond `SCREENCI_SECRET`. With the secret in
your project env file, the CLI exchanges it for this machine's personal
editor token automatically on first use and saves it as
`SCREENCI_EDIT_TOKEN` (exchanged tokens are named after the machine and stay
listed and revocable on the Secrets page, up to five per user; you can still
create one by hand there and set it yourself). Run `screenci preview --watch` in
your project and the editor header shows your machine as connected (for
example `you@laptop`) and offers "Record ... on laptop" in the Export
button's record menu.
create one by hand there and set it yourself).
Without an account, no setup is needed: `screenci preview` runs under a local,
anonymous trial session (see
[Anonymous Trial](/docs/guides/anonymous-trial)), and the anon session token
doubles as the editor token. The trial's editor page stays editable even
while no machine is connected: edits queue server-side and land in your
sources on the next `screenci` command. Signing up claims the trial: a
running `--watch` session picks up your new `SCREENCI_SECRET` and a
claim-minted `SCREENCI_EDIT_TOKEN` (written into `screenci/.env`) and
reconnects without restarting.
While connected:
- Only your own account can trigger records on your machine. Teammates see
whose machine is connected but cannot use it.
- A triggered record runs exactly one video in one language (the one open in
the editor), then uploads as a normal record run: it refreshes the live
preview, and renders only when the trigger was an export. The editor's
record menu also offers "Record raw preview footage", which records without
rendering at all.
- Web edits are applied to your sources as you make them in the editor; the
editor shows a syncing state on your machine's chip while a change is being
written.
- The regular record run lock applies: if another recording run is already
active on the machine, the request is reported back to the editor as failed.
- Stopping the command (Ctrl-C) disconnects the machine; the editor updates
within about fifteen seconds.
doubles as the editor token. Signing up claims the trial: the next
`screenci` command picks up your new `SCREENCI_SECRET` and a claim-minted
`SCREENCI_EDIT_TOKEN` (written into `screenci/.env`).
Editor tokens are separate from `SCREENCI_SECRET`: the secret authenticates your
project to the service, the editor token additionally proves which user and
machine is listening. Revoking the token on the Secrets page immediately
disconnects the machine.
## `screenci sync`
Pulls queued web edits into your `.screenci.ts` sources in one shot and
exits: it connects briefly, claims every edit queued while no machine was
connected, writes each into the source by its `editId`, and reports the
result back so the editor's pending queue clears. This is the editing loop in
its smallest form: edit in the web editor, edits queue, and the next sync
writes them into your code. `test` and `preview` run the same sync
automatically on start; `export` deliberately does not (it warns about queued
edits and renders your sources exactly as they are), so run `sync` or
`preview` first when you want those edits in the export.
```bash
screenci sync
```
Options:
- `-c, --config <path>`: path to the ScreenCI config file.
machine recorded. Revoking the token on the Secrets page immediately
invalidates it.
## `screenci info`
+105 -219
View File
@@ -2,27 +2,22 @@
Editor is the ScreenCI web app's editing surface for a video: a live preview of
the raw recording, a multi-track timeline, and panels for narration, overlays,
and render options. You edit visually in the browser, and every change is
written back into your `.screenci.ts` source through a connected
`screenci preview` machine, so code stays the single source of truth.
and render options. Your `.screenci.ts` sources declare the video and record
the footage; edits made in the editor live in the web app and shape every
preview and render of that video. Editor edits are not written back into your
sources.
**You can edit without a connected machine.** Anyone in your org can open a
video and change narration, overlays, render options, and the rest right away.
Those edits render immediately in the preview and in exports. Because code stays
the single source of truth, each edit is also queued to be written into your
`.screenci.ts` source: the sidebar shows an "N edits pending" list ("not yet in
code"). Run `screenci preview` in your project and the next connect drains that
queue into the sources, attributing each edit to whoever made it. A teammate can
make the edits and a developer's machine can pick them up later.
**Anyone in your org can edit.** Open a video and change narration, overlays,
render options, and the rest right away; the edits render immediately in the
preview and in exports, no connected machine needed.
Edits that change what is captured (record options, interaction timings,
on-screen text, the language set) cannot take effect until a recording runs.
They are queued the same way, but the editor badges them "applies after next
recording" and marks the preview stale rather than pretending they took effect.
Applying the edit to code never re-records on its own; the next recording
(a connected machine's preview, CI, or `screenci export`) bakes them in.
While a connected machine is actively syncing
a video's source, that video's editing controls lock briefly until it finishes.
The editor badges them "applies after next recording" and marks the preview
stale rather than pretending they took effect. The next recording (a preview
from your machine, CI, or `screenci export`) bakes them in. While a machine is
actively recording a video, that video's editing controls lock briefly until
it finishes.
**Everything is editable by default.** Every feature a video declares
(narration, overlays, languages, render and record options) can
@@ -34,14 +29,8 @@ make in code is where the content starts:
content is filled in on the Editor page.
- **Plain objects are code values.** `video.narration({ intro: 'Welcome' })`
supplies the content from code. It stays fully editable in the web app:
editing it queues a code write, and once the write syncs, the code (now
carrying the edited value) is what every later upload renders with. There
is no app-side override that shadows code.
- **Edits write back to code.** Whichever form declared a value, editing it in
the web app produces a code change applied by your connected `screenci preview`
machine, so the sources always show what the video renders with.
supplies the starting content from code. It stays fully editable in the web
app: an edit overrides the code value for rendering from then on.
The `video.narration` and `video.overlays`
declarations type the matching fixtures to exactly those names, so a typo is a
@@ -61,13 +50,13 @@ video.overlays(['intro', 'logo'])
// Plain objects: code values, used at record time, editable in the web app.
video.narration({ en: { intro: 'Welcome', outro: 'Thanks' } })
// Languages: the code set. Adding a language in the editor writes it here.
// Languages: the code set. The editor can add more on top of it.
video.languages(['en', 'fi']) // the code language set
video.languages({ languages: ['en', 'fi'], mode: 'shared' }) // set with capture options
// Render / record options: code values are the starting point. Editing them in
// the editor writes the change back into these builder calls (renderOptions
// supports per-language overrides via { default, <lang> }).
// Render / record options: code values are the starting point; the editor
// can override them (renderOptions supports per-language overrides via
// { default, <lang> }).
video.renderOptions({ output: { aspectRatio: '9:16' } })
video.recordOptions({ fps: 30 })
```
@@ -75,7 +64,7 @@ video.recordOptions({ fps: 30 })
#### You will learn
- [how the editor is laid out and what each part does](#the-editor-at-a-glance)
- [how pending edits sync to code](#pending-edits-and-code-sync)
- [where edits live](#where-edits-live)
- [how to edit and export a video in Editor](#editing-in-editor)
- [how to record from the editor](#recording-from-the-editor)
- [how to manage narration from Editor](#editor-narration-from-code)
@@ -84,7 +73,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 reach code](#how-edits-reach-code)
- [how edits and code fit together](#how-edits-and-code-fit-together)
- [how action parameters are tracked and overridden](#action-parameter-tracking-and-overrides)
- [how to migrate from the removed `editable()` helper](#migrating-from-editable)
@@ -122,45 +111,23 @@ Opening a video in the web app opens the editor. The page is laid out as:
- **Top right**: undo and redo (up to 20 steps, Cmd+Z / Shift+Cmd+Z), export
status, and the **Export** button.
## Pending edits and code sync
## Where edits live
Every edit is ultimately a code change: code stays the single source of truth.
But you do not need a connected machine to edit. Anyone in the org can change a
video, and the edits render immediately while they wait to be written into the
`.screenci.ts` source. The sidebar's **pending** list shows how many edits are
"not yet in code" and who queued each one.
To flush the queue into your sources, run `screenci sync`, `test`, or
`preview` in the project: they all drain it on start. (`export` deliberately
does not apply edits: it warns about queued ones and renders your sources
exactly as they are.) On connect, the CLI writes every queued edit into the
source and prints one summary line ("Synced 3 editor edits into your
sources."); a live `screenci preview --watch` session logs each edit as it
lands, naming the teammate who queued it. No token setup is needed: with a
`SCREENCI_SECRET` configured, the CLI mints this machine's personal editor
token automatically (it stays listed and revocable on the Secrets page).
On an anonymous trial (no account), `screenci preview` connects with the trial
session itself, and the trial's editor queues edits the same way while no
machine is connected.
If an edit can no longer be applied (its target was renamed or removed in code,
or the source drifted), it stays in the list as failed with **Retry** and
**Discard**. Discarding abandons only the code write: the value it set keeps
rendering.
Editor edits live in the ScreenCI backend, per video. They render immediately
in the preview and in every later export, and they survive re-records: an edit
is keyed to the action it belongs to (its stable `editId` slug), so it stays
in place when fresh footage lands. Your `.screenci.ts` sources stay the
declaration of the video (what is recorded and in which order); the editor
owns the visual polish layered on top. Edits are not written back into the
sources.
Edits that only affect rendering (narration text, overlay files, render
options) preview and export immediately; when a live `--watch` session writes
one into code it logs "Applies at render time, no re-record needed" and does
not re-record (the kept recording is re-baselined to the rewritten source, so
it stays fresh).
options) preview and export immediately.
Edits that change the capture itself
(record options, interaction timings, on-screen text, the language set) are
badged **applies after next recording**: the preview is marked stale until a
recording runs. Applying such an edit to code never re-records on its own;
trigger a re-record from the editor or via CI (see the CI setup guide), or ask
a developer to run `screenci preview` or `screenci export`. While a connected machine is actively syncing a
video's source, that video's editing controls lock briefly until it finishes.
recording runs. Trigger a re-record via CI (see the CI setup guide), or run
`screenci preview` or `screenci export` in the project.
## Editing in Editor
@@ -170,24 +137,17 @@ wait for content, and values declared in code show their current code value as
the starting point.
Items whose current value still comes from code are marked with a **set in
code** badge. Editing such an item queues a write-back into your source (applied
by the next connected machine), so code and editor never drift apart.
code** badge. Editing such an item stores the new value as an editor edit that
overrides the code value from then on.
Pick a language in the sidebar, then choose **Export** to export a new version
in that language. Exports are per language: switch the language and export
again to update another localized version. If edits that need a new recording
are pending and your machine is connected, Export records first and then
renders. Exported versions appear in the sidebar's **Exported** group, with a
are pending, record fresh footage first and then export. Exported versions appear in the sidebar's **Exported** group, with a
status glyph while rendering and a marker on the version served at the public
URL.
How editing works, in one loop: edit in the web editor; edits queue; the
next `screenci sync`, `test`, or `preview` writes them into your code; both
recordings and renders then run purely from what the code says. Until the
sync happens, the editor keeps showing your pending edits (they are the
queue itself), and the pending list names each edit still "not yet in code".
The one exception is editor-uploaded media: overlay files, audio tracks,
Editor-uploaded media is stored alongside the edits: overlay files, audio tracks,
uploaded narration audio, and cloned voices. Their bytes live in the ScreenCI
backend (code references them as `{ editor: '<name>' }`), so they are merged
onto every later upload automatically and apply at render time. When that
@@ -202,22 +162,18 @@ Editor-uploaded media for "Checkout walkthrough" applies at render time; recordi
The sidebar's **Recording** group collects every way to produce fresh footage:
- **Record on your machine**: with `screenci preview` connected, the record menu
offers "Record <language> on <machine>". This runs a normal local record of
the open video and language on your machine and syncs the result back.
- **Record raw preview footage**: records without rendering, refreshing the
live preview only. This is also what automatic preview re-records use.
- **Record via CI**: when the project is connected to GitHub, queues the
project's recording workflow for this video, no local machine needed.
- **Record locally**: run `screenci preview` (or `screenci export`) in the
project to record fresh footage from your machine.
When the CLI starts recording a preview, the open web preview page shows a
live "Recording preview..." indicator, and it updates automatically (with a
"New preview loaded" toast) once the new preview lands.
A status line under the menu tracks the run ("Recording en on laptop...",
"Recording synced."). The regular record run lock applies: if another
recording run is already active on the machine, the request is reported
back as failed.
A status line under the menu tracks the run. The regular record run lock
applies: if another recording run is already active on the machine, the run
fails instead of sharing the same output.
## Editor narration from code
@@ -257,7 +213,7 @@ the hold together with a direct link to Editor:
```
Rendering for "Checkout walkthrough" is on hold. Configure it in Editor:
https://app.screenci.com/project/<projectId>/video/<videoId>/preview
https://app.screenci.com/project/<projectId>/video/<videoId>?editor
```
After the video has been configured once, subsequent uploads reuse the saved
@@ -417,21 +373,18 @@ see [Editor languages from code](#editor-languages-from-code) below. There is no
## Editable timeline actions
Interaction timings, zoom options, speed blocks, and pauses can be edited from
the web timeline, without hand-editing code: each saved edit is written into
the sources for you and picked up by the next record.
the web timeline, without hand-editing code: each saved edit is stored in the
editor and picked up by every later preview and render.
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. Code is the single source of truth: while
`screenci preview` 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.
plus its position on the timeline; edits are keyed by the action's `editId`
slug so they survive re-records.
Cursor-move fields (`move.duration`/`move.speed`, `move.easing`, `move.curve`,
`move.curviness`, `move.delayAfter`), action durations, and pre-action pauses
are all written as the matching option on the `editId`-stamped call. The
are all edited as the matching option of the `editId`-stamped call. The
cursor path's curve can be edited visually in the preview by dragging its
bezier handles.
@@ -494,14 +447,12 @@ different on purpose: a click or tap always stays where the test code performed
only its parameters (durations, sleeps) are editable.
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 preview` session). A newly added event appears on the
timeline as a pending item until the next record confirms it.
position, saved in the editor the moment it is placed.
A web-authored event can be deleted again: select it and press **Delete** or
**Backspace**, or right-click it and choose **Delete**. Deleting removes the
edit from both the editor and the source (the same path "Reset all" uses).
Recorded interactions are code-owned and cannot be deleted this way.
**Backspace**, or right-click it and choose **Delete** (the same path "Reset
all" uses). Recorded interactions are code-owned and cannot be deleted this
way.
Events are added in two ways:
@@ -532,28 +483,10 @@ 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.
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 fails the codegen request and the
editor reverts the optimistic value instead of dropping it silently. The
failure carries a typed reason plus a message, surfaced in the editor's
pending-sync queue (with per-row retry/discard and a clear-all), so the user
sees what to fix: `unknown-edit-id`, `ambiguous-edit-id`, `inside-control-flow`,
`unstamped-action`, `loop-repeat`, `unsupported-field`, `invalid-edit`,
`unresolved-import` (the effect function needs a named import from
'screenci'), `unknown-video`, `app-managed`, or `unsupported-shape`.
One reason is not a failure: `orphaned-override` means the override's key is
absent from the current recording snapshot (its action was removed, or an
ordinal-keyed target such as a `waitForTimeout` delay drifted since the edit
was authored). There is no call site to write, so the request is reported as a
soft skip and auto-discarded (resolved as superseded, no re-record) rather than
surfaced as something the user must clear by hand.
Aliased imports are supported throughout: a file that does
`import { autoZoom as az } from 'screenci'` has its `az(...)` wraps
recognised, updated, and unwrapped like the canonical name, and codegen reuses
the alias when inserting new calls.
An edit whose target action is absent from the current recording snapshot
(its action was removed, or an ordinal-keyed target such as a
`waitForTimeout` delay drifted since the edit was authored) is discarded as
orphaned rather than surfaced as something the user must clear by hand.
## Effects in code: block wrappers and gap sleeps
@@ -623,15 +556,14 @@ 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 editor edits are codegen'd into these
same call-position statements, keyed by each action's `editId`.
shows this same linear timeline, and editor edits are stored as the same
call-position statements, keyed by each action's `editId`.
### Splitting and trimming the recording from the web editor
The web timeline has a scissors mode: clicking the recording track cuts it at
that instant. A bare split is stored as a zero-width `hide` span edit. It is
editor-only state: codegen never writes an empty `hide(async () => {})` into
code, so an untouched split just stays editable on the web.
that instant. A bare split is stored as a zero-width `hide` span edit and
stays editable on the web.
A cut snaps to where it will actually land, and the guide line (plus the live
preview, when paused) tracks that snapped point rather than the raw cursor. A
@@ -645,39 +577,32 @@ gap sleep; once such a span reaches code it opens with a leading
Dragging a split's edges inward swallows footage (and the interactions in it)
into the hide; the span edit is re-anchored to whole interactions, with
`waitForTimeout` sleeps preserving any partial gap on both sides. Dragging back
out restores the footage. Once the trimmed span reaches code, it is a regular
out restores the footage. The trimmed span behaves like a regular
`hide(...)` block.
### Removing a code block from the web editor
A block carrying an `editId` (`hide(fn, { editId: 'setup' })`, and likewise
`speed`/`time`) can be removed from the web editor (merge two recording
sections, reset a trim). This sends a `blockRemoveEdit` targeting the block's
editId; the codegen channel unwraps the block in source, keeping the wrapped
sections, reset a trim). This stores a `blockRemoveEdit` targeting the
block's editId; the render treats the block as unwrapped, keeping the wrapped
calls (any `waitForTimeout` pacing inside survives as plain gap sleeps).
Blocks without an editId get one stamped automatically when an edit session
starts, so every block becomes web-removable.
### Splitting a camera zoom in two
An `autoZoom` bracket on the Zooms row can be split into two back-to-back
brackets from the web editor: enter split mode (the scissors) and click the
zoom at the interaction boundary where it should break. A web-added
(pending) zoom is split by rewriting its own edit record. A code-authored
`autoZoom` is split through the codegen channel: the editor sends a
`blockRemoveEdit` for the original bracket's `editId` (which now unwraps
`autoZoom` blocks, not only `hide`/`speed`/`time`) plus two `zoomEdit`s over
the two interaction sub-runs, each carrying the original zoom options
(`amount`/`duration`/`easing`/`centering`) so the halves are identical apart
from their time. The unwrap is ordered before the two re-wraps in one sync
pass, so the result is two sibling `autoZoom(...)` blocks. Because this
rewrites the source, splitting a code zoom needs a connected `screenci preview`
session; with no machine connected the editor declines rather than storing a
deferred edit. A zoom framing a single interaction cannot be split.
zoom at the interaction boundary where it should break. A web-added zoom is
split by rewriting its own edit record. A code-authored `autoZoom` is split
by storing a `blockRemoveEdit` for the original bracket's `editId` plus two
`zoomEdit`s over the two interaction sub-runs, each carrying the original
zoom options (`amount`/`duration`/`easing`/`centering`) so the halves are
identical apart from their time. A zoom framing a single interaction cannot
be split.
Overlays and narration cues are not yet splittable from the web editor: their
placements are stored as points (a start position, not a code-level span), so
there is no duration to divide. Splitting those remains a source edit.
there is no duration to divide.
### Actions inside `hide()`
@@ -687,38 +612,18 @@ no input events, but each one records a small `hiddenAction` marker
Renderers ignore these markers; the web editor uses them to know what a hide
was suppressing.
## How edits reach code
## How edits and code fit together
Code is the single source of truth, and the loop is a single step:
Code declares the video and records the footage; the editor layers edits on
top:
1. **Connect.** Run `screenci preview --watch` 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, a codegen'd edit
inserts the same call you would have written by hand.
### Formatting codegen edits
After an edit is written, the CLI formats the changed file with your
project's own Prettier install. `screenci init` enables this by scaffolding a
minimal `.prettierrc` (2-space indent, single quotes, no semicolons, matching
the generated examples) and installing `prettier` in the project. Formatting
runs only when both are present: edit `.prettierrc` to change the style, or
delete it (or uninstall `prettier`) to keep the raw codegen output. A
formatting failure never fails the edit; the unformatted change is written
and a warning is logged.
1. **Record.** `screenci preview` (locally or in CI) records purely from code,
so the footage always matches what the sources say.
2. **Edit in the web timeline.** Each saved edit is stored in the editor,
keyed to its action's `editId` slug (or its call position), and applies to
every later preview and render of the video.
3. **Re-record freely.** Because edits are keyed to stable action identities,
they stay in place when fresh footage lands.
## Action identity: editId
@@ -736,21 +641,18 @@ 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. An action that has not been stamped yet falls back to a readable
changes. An action that has not been stamped yet falls back to a readable
identity key built from what was recorded (`delay`, `input click Save`, with
`#2` appended for repeat executions); these keys can drift across re-records,
which is why stamping exists.
The slug is the action's display name on the editor timeline, and it can be
renamed there: the rename is codegen'd by replacing the slug's string literal
in code.
The slug is the action's display name on the editor timeline.
Because the slug IS the identity, two distinct actions must never share one. A
copy-pasted `editId` silently merges both into a single identity (the second
looks like a loop repeat and its edits cannot reach code). Static analysis
looks like a loop repeat and cannot hold its own edits). Static analysis
guards against this automatically: before recording, and during the
`screenci preview` startup handshake and its codegen apply, any slug found at two
`screenci preview` startup handshake, any slug found at two
or more distinct call sites is resolved by keeping the first occurrence and
re-stamping the rest with fresh slugs (allocated from `.screenci/edit-ids.json`,
so they never collide with an existing id). A genuine loop (one call site that
@@ -759,10 +661,9 @@ Resolving needs the `typescript` package resolvable from your project; when it
is missing, the CLI warns only if a possible duplicate was actually detected,
and leaves the sources unchanged.
editId is optional until edits need to reach code. Actions without one keep
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
editId is optional. Actions without one keep the matcher-based identity
(locator description + occurrence), which can drift across re-records. 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.
@@ -792,46 +693,33 @@ time 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
## Option panels and narration text are editable too
The editor's option panels are codegen'd the same way as timeline edits while
`screenci preview` is connected (the studio config keeps working as the instant
preview and the offline fallback):
The editor's option panels store their edits the same way as timeline edits:
- **Render options** (recording size and roundness, background, aspect ratio,
quality, mouse size/style/motion blur, 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.
Since they change recorded behavior, they apply at the next recording.
- **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.
box styling, shadow, crop) override the video's `.renderOptions({...})`
values key by key; unrelated keys keep their code values.
- **Record options** override `.recordOptions({...})` the same way. Since
they change recorded behavior, they apply at the next recording.
- **Narration text** overrides the `video.narration(...)` declaration per
cue: a new cue key can be added, an existing value replaced, and per-cue
volume adjusted. Editing a non-default language stores that language's own
values without touching the others.
Every editor edit is codegen'd: it is written into your `.screenci.ts`
sources by the next connected `screenci` command (queuing server-side while
no machine is connected). There is no web-side edit store: an unsynced edit
exists only as its queued code write, which the editor also renders as a
pending overlay. The exception is uploaded media bytes (overlay files, audio
tracks, uploaded narration audio, cloned-voice samples): those live in the
ScreenCI backend and are referenced from code as `{ editor: '<name>' }`.
Uploaded media bytes (overlay files, audio tracks, uploaded narration audio,
cloned-voice samples) live in the ScreenCI backend and are referenced from
code as `{ editor: '<name>' }`.
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.
call site (keys like `click1#1`) cannot be edited per execution. 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
in git (or edit it by hand) and record again. There is no separate web edit
layer to reset.
Use undo in the editor (up to 20 steps, Cmd+Z / Shift+Cmd+Z), or reset an
edited value back to its code value from its panel.
## Editor languages from code
@@ -846,10 +734,8 @@ declaration (overlays are shared across languages). When you add a language to a
narrated video, the editor offers to auto-translate the existing narrations into
it, or start it with empty placeholders. The **Languages** section on the Editor
page shows the
current set and lets you add a language; adding one writes it into your
`video.languages([...])` declaration in code (a new `.languages([...])` call is
added when the video has none) through the connected `screenci preview` machine,
then records:
current set and lets you add a language on top of the code set, then
records:
```ts
import { video } from 'screenci'
+6 -11
View File
@@ -218,20 +218,15 @@ language keys, falling back to the implicit `en` default for a plain video.
The language menu on the Editor page lists the current languages and lets you
add one. Adding a language auto-translates the existing narration from the
language you are viewing and writes the new language into your
`video.languages([...])` declaration in code (a new `.languages([...])` call is
added when the video has none). The code edit applies through the connected
`screenci edit` machine; with no machine connected it is queued and applies
automatically the next time one connects. The new language is watchable in the
language you are viewing and adds the new language on top of your
`video.languages([...])` code set. The new language is watchable in the
live preview immediately (it borrows another language's footage until its
first record) and you can edit every translated line afterwards.
Deleting a language from the same menu removes its exports and narrations and
removes the language from the `video.languages([...])` declaration the same
way (queued when no machine is connected). The code edit also removes the
language's entries from language-major `video.narration({...})` and
`video.values({...})` declarations, so its cue texts do not linger in your
sources. The default language cannot be deleted.
Deleting a language from the same menu removes its exports and narrations.
A language declared in code (in `video.languages([...])` or a language-major
`video.narration({...})`) comes back on the next recording unless it is also
removed from the sources. The default language cannot be deleted.
## Available languages
+6 -8
View File
@@ -127,7 +127,7 @@ video
// Open with a brief brand intro card before the walkthrough begins.
await overlays.logo.for(2000)
// Play the narration line for this step.
// Play the narration for this part of the flow.
await narration.docs()
// Automatically zoom into interactions so they are easier to follow.
@@ -235,13 +235,11 @@ yarn screenci preview
</Tabs>
`preview` records every video whose live preview is stale, uploads the
previews, and prints the link: a single recorded video gets its preview page
directly, several get a run listing page that links each one. Run it with
`--watch` to connect your machine as the code-sync bridge for one video,
which unlocks editing in the browser: narration text and voices, overlays,
cuts and pacing on the timeline, and render options. Every edit is written
back into your `.screenci.ts` script through the connected machine, so the
code always matches what you see. The live preview is free to iterate on:
previews, and prints the link: a single recorded video gets its video page
directly, several get a run listing page that links each one. From the video
page you can open the editor and refine the video in the browser: narration
text and voices, overlays, cuts and pacing on the timeline, and render
options. The live preview is free to iterate on:
record, watch, adjust, and record again without spending anything. See
[Editor](/docs/editor) for the full tour. To narrow the run to one video,
pass a title pattern (`npx screenci preview "Onboarding"`).
+13 -4
View File
@@ -65,10 +65,9 @@ video.narration({ en: { intro: 'Welcome.' } })
**2. Blank names (start empty, filled from the editor).** Pass a bare array
of cue names: the names exist in code (so the body can call
`narration.intro`), and their content starts empty. Rendering holds until
each cue gets content. Writing a cue's text in [Editor](./editor.md) codegens
it into your sources, converting the array to the object form on the first
edit: the edited cue becomes a seeded value, untouched names stay blank as
`{ editor: '<name>' }`, and the next synced recording renders it. (A cue can
each cue gets content. Write the cue's text in [Editor](./editor.md) (the
edit lives in the editor and applies to every later render), or fill it in
code. (A cue can
instead be satisfied by editor-uploaded audio or a cloned voice, which stay
backend-hosted.) Chain `.languages([...])`, since there is no text to infer
the set from.
@@ -195,6 +194,16 @@ even starts is ignored with a warning.
Keep cues small. In practice, one sentence per cue is the safest default for
timing, overlap control, and subtitle readability.
## Writing narration
Narrate what the flow accomplishes, not each click. Viewers can see the
cursor; the narration's job is to explain the goal ("Invite your teammates
and set their roles"), so a few broad cues cover a whole section of the
video. Borrow the product's own vocabulary from its source code, UI copy, and
domain terms, so the voice-over sounds native to the app. When a demo fills
in a form, use plausible fictitious details ("Emma Carter",
"emma@aperturebio.com"), never real names, emails, or addresses.
If only one file needs a different narration layout, pair `video.narration(...)`
with `video.renderOptions()` instead of changing the whole project:
+3 -3
View File
@@ -14,9 +14,9 @@ final video where your team needs it.
- **Record and refine.** `screenci preview` captures the flow on your machine,
uploads the live preview, and opens the video in the [Editor](/docs/editor):
a live preview and a timeline. While `preview --watch` stays running, every change you
make in the browser (narration, overlays, cuts, timings, render options) is
written back into your script, so code stays the source of truth.
a live preview and a timeline. Every change you make in the browser
(narration, overlays, cuts, timings, render options) applies to the preview
and later exports right away.
- **Export.** When the preview looks right, run `screenci export` (or use the
Export button) to render the finished videos and download them, then serve
them from a stable public URL. `export` re-records only the videos whose
+4 -1
View File
@@ -77,7 +77,7 @@ video
// Open with a brief brand intro card before the walkthrough begins.
await overlays.logo.for(2000)
// Play the narration line for this step.
// Play the narration for this part of the flow.
await narration.docs()
// Automatically zoom into interactions so they are easier to follow.
@@ -327,6 +327,9 @@ Prefer one sentence per cue. Split longer narration into separate named cues and
place them where they belong in the flow. That gives you cleaner overlap
control, makes revisions less brittle, and should save API cost when a TTS
provider such as ElevenLabs only needs to regenerate one changed sentence.
Write cues about what the flow accomplishes rather than individual clicks,
and fill forms with fictitious example data; see
[Narration](/docs/guides/narration) for style guidance.
To control which languages are recorded, chain `video.languages(...)` (a plain
array of language codes, or `{ languages, mode }`). The recorded set is the
+1 -1
View File
@@ -20,7 +20,7 @@ video
// Open with a brief brand intro card before the walkthrough begins.
await overlays.logo.for(2000)
// Play the narration line for this step.
// Play the narration for this part of the flow.
await narration.docs()
// Automatically zoom into interactions so they are easier to follow.
+9 -11
View File
@@ -28,14 +28,9 @@ npx screenci test
# run a subset with normal Playwright filters
npx screenci test recordings/signup.screenci.ts --grep "fills billing details"
# once tests pass, record the free live preview and print the web editor link
# (one-shot: it syncs queued browser edits into the scripts, records, and exits)
# once tests pass, record the free live preview and print the video link
npx screenci preview "Video title"
# pull queued browser edits into the scripts at any time
# (test, export, and preview also do this automatically on start)
npx screenci sync
# only export when the finished videos are wanted
npx screenci export
```
@@ -81,6 +76,8 @@ video.renderOptions({ narration: { voice: { name: voices.Ava } } }).narration({
- Declare `video.narration({ ... })` on every video and speak throughout the demo. Pass a flat `cue -> text` object (shared across languages) or one keyed by language (`en`, `es`, ...).
- The opening line must state the video's purpose, then continue with the walkthrough.
- **Narrate the flow, not the clicks.** Each cue describes what the user is achieving ("Invite your teammates and set their roles"), never the mechanics ("Now click the blue button"). A handful of broad cues covering the whole flow beats one cue per action.
- **Use the product's own vocabulary.** Pull nouns and verbs from the recorded app's source code and on-screen copy (page titles, button labels, domain terms) so the narration sounds native to the product.
- Trigger cues from the `narration` fixture: `await narration.key()` runs the full line before moving on. Use `await narration.key.start()` when narration should overlap the next action, and `await narration.key.end()` to close that cue later, especially before visible navigation or route changes.
- Use inline speech tags when needed: `[pronounce: ...]`, `[short pause]`, `[medium pause]`, `[long pause]`. Always guide pronunciation for URLs and domains, e.g. `screenci.com [pronounce: screen see eye dot com]`.
@@ -89,7 +86,8 @@ video.renderOptions({ narration: { voice: { name: voices.Ava } } }).narration({
Every video MUST follow these:
- **Narration on every video, no exceptions.** Videos without narration are not acceptable.
- **Open with the video's purpose** before the step-by-step.
- **Open with the video's purpose**, then narrate the flow at a high level.
- **Example data only in forms.** Fill forms with plausible fictitious names, emails, and addresses (e.g. `Emma Carter`, `emma@aperturebio.com`), never real people or real contact details.
- **Start on the requested page.** The visible video begins on the page the user asked for.
- **Hide initial setup.** Wrap page load, auth, navigation to the start page, loading spinners, and cookie-banner dismissal in `hide()`. After the initial navigation, find and click any cookie consent accept button inside that hidden block.
- **Navigate visibly with clicks** after hidden setup, not `page.goto()`.
@@ -104,7 +102,7 @@ Prefer stable manual zoom for edit-heavy sections; use `autoZoom()` for movement
```ts
// Forms and steady editing: fixed frame.
await zoomTo(page.getByRole('form', { name: /profile settings/i }))
await page.getByLabel('Name').fill('Jane Doe')
await page.getByLabel('Name').fill('Emma Carter')
await page.getByRole('checkbox', { name: 'Email notifications' }).check()
await page.getByRole('button', { name: 'Save changes' }).click()
await resetZoom()
@@ -133,10 +131,10 @@ The secret is the only credential to configure. The CLI mints this machine's per
1. Add or edit `.screenci.ts` files in `recordings/` (remove `example.screenci.ts` if creating new videos).
2. Run `npx screenci test` until it passes. Fix selectors/flow/narration and rerun until green.
3. Once tests pass, run `npx screenci preview "<title>"` yourself. Do not export first. It is one-shot: it syncs any queued browser edits into the script, records the video's live preview if stale (free, no render), prints the web editor link, and exits. `preview` works without an account: with no `SCREENCI_SECRET` it runs under a free anonymous trial session. Browser edits made while no machine is connected queue server-side and land in the script on the next `screenci sync`, `test`, or `preview` (`export` never applies edits; in CI, `preview --no-sync` also skips the edit sync so the checkout stays read-only).
4. Report the editor link `preview` printed so the user can review and refine the video in the browser. Browser edits queue server-side and land in the script on the next `screenci` command, so there is no need to keep a session running.
3. Once tests pass, run `npx screenci preview "<title>"` yourself. Do not export first. It records the video's live preview if stale (free, no render), prints the video link, and exits. `preview` works without an account: with no `SCREENCI_SECRET` it runs under a free anonymous trial session.
4. Report the video link `preview` printed so the user can review and refine the video in the browser.
5. Run `npx screenci export` only when the user wants the finished videos. Exporting requires an account with an active paid subscription: without one, `export` refuses and prints a sign-up link (the anonymous trial is preview-only). With one, it records what changed, renders, waits, and downloads into `./exports/`. ScreenCI writes `.screenci/<video-name>/recording.mp4` and `data.json` per re-recorded video.
6. After `export`, report the URL it printed (starts with the app's domain, e.g. `https://app.screenci.com/export/...`) so the user can open it.
6. After `export`, report the URL it printed so the user can open it (a single video links its page, e.g. `https://app.screenci.com/project/<projectId>/video/<videoId>?export=...`; several videos link the run page `https://app.screenci.com/export/...`).
`screenci init` (or `npm init screenci`) scaffolds a new project and fails on purpose if one already exists (`screenci/ already exists`). That is expected: keep working with the existing project, do not delete it to re-init.
+2 -2
View File
@@ -36,7 +36,7 @@ To connect an existing organization, get `SCREENCI_SECRET` into `screenci/.env`
- Recording runs with local Playwright.
- `export` needs an active paid subscription; renders and downloads land in `./exports/`.
- After a successful `export`, report the URL it printed (starts with the app's domain, e.g. `https://app.screenci.com/export/...`) back to the user so they can open it.
- After a successful `export`, report the URL it printed back to the user so they can open it (a single video links its page, e.g. `https://app.screenci.com/project/<projectId>/video/<videoId>?export=...`; several videos link the run page `https://app.screenci.com/export/...`).
## Recommended Workflow
@@ -55,7 +55,7 @@ npx screenci export
Always run `npx screenci test` until it passes first. Fix failures and rerun until green.
Once tests pass, prefer `npx screenci preview "<title>"` over exporting right away: it records the live preview if stale (free, no render), prints the web editor link, and exits; browser edits queue server-side and land in the script on the next `screenci sync`, `test`, or `preview` (`export` never applies edits; it warns about queued ones and renders the sources as they are). Report the link so the user can review the video. `preview` works with or without an account (without one it runs under the free anonymous trial).
Once tests pass, prefer `npx screenci preview "<title>"` over exporting right away: it records the live preview if stale (free, no render), prints the video link, and exits. Report the link so the user can review the video. `preview` works with or without an account (without one it runs under the free anonymous trial).
Run `npx screenci export` when the user wants the finished videos: it records what changed, renders, and downloads them. Exporting requires an account with an active paid subscription.
+1 -1
View File
@@ -4,7 +4,7 @@
* 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; the
* web editor's edits themselves are codegen'd straight into the sources by
* `screenci edit`, so the recorded code values are always what a run used.
* the editor, so the recorded code values are always what a run used.
*/
import {
DEFAULT_CLICK_MOUSE_MOVE_DURATION,
+1 -1
View File
@@ -134,7 +134,7 @@ export type AnonSessionStatus =
// `editToken`: a personal editor token the server minted for the claiming
// user (absent on sessions claimed before the anonymous edit bridge, or
// when the user was at the token cap). Persisted next to the secret so
// `screenci edit` keeps working after the claim.
// `screenci preview` keeps working after the claim.
| { status: 'claimed'; secret: string; editToken?: string }
/**
-620
View File
@@ -1,620 +0,0 @@
import { describe, expect, it } from 'vitest'
import ts from 'typescript'
import {
applyCodegenRequest,
requireTypescriptForCodegen,
} from './applyCodegen.js'
import type { EditableSnapshot } from './editableSnapshot.js'
import { planDuplicateEditIdFixes, readEditIdCounters } from './editIdStamp.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 },
},
],
},
}
async function apply(
editJson: string,
source: string = SOURCE,
formatFile?: (path: string, content: string) => Promise<string>
) {
const writes: Record<string, string> = {}
await 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,
...(formatFile !== undefined && { formatFile }),
}
)
return writes
}
describe('applyCodegenRequest: options and narration records', () => {
it('writes an optionsEdit as a new .renderOptions call', async () => {
const writes = await 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', async () => {
const source = SOURCE.replace(
"video('Demo'",
"video.recordOptions({ headless: false })('Demo'"
)
const writes = await 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', async () => {
const source = SOURCE.replace(
"video('Demo'",
"video.renderOptions({ fps: 60 })('Demo'"
)
const writes = await 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', async () => {
const writes = await 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', async () => {
const source = SOURCE.replace(
"video('Demo'",
"video.narration({ intro: 'Hi' })('Demo'"
)
const writes = await 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('writes a valuesEdit into the declaration, adding the section', async () => {
const writes = await apply(
JSON.stringify({
type: 'valuesEdit',
id: 'values|title|default',
field: 'title',
lang: 'default',
value: 'Welcome',
})
)
expect(writes[FILE]).toContain("video.values({ title: 'Welcome' })('Demo'")
})
it('converts a names-only values array to an object literal', async () => {
const source = SOURCE.replace(
"video('Demo'",
"video.values(['title', 'subtitle'])('Demo'"
)
const writes = await apply(
JSON.stringify({
type: 'valuesEdit',
id: 'values|title|default',
field: 'title',
lang: 'default',
value: 'Welcome',
}),
source
)
expect(writes[FILE]).toContain(
"video.values({ title: 'Welcome', subtitle: '' })('Demo'"
)
})
it('writes a languagesEdit as a new .languages call', async () => {
const writes = await apply(
JSON.stringify({
type: 'languagesEdit',
id: 'languages',
languages: ['en', 'fi'],
})
)
expect(writes[FILE]).toContain("video.languages(['en', 'fi'])('Demo'")
})
it('extends an existing .languages array', async () => {
const source = SOURCE.replace(
"video('Demo'",
"video.languages(['en'])('Demo'"
)
const writes = await apply(
JSON.stringify({
type: 'languagesEdit',
id: 'languages',
languages: ['en', 'fi'],
}),
source
)
expect(writes[FILE]).toContain("video.languages(['en', 'fi'])('Demo'")
})
it('removes a language via a languagesEdit with removeLanguages', async () => {
const source = SOURCE.replace(
"video('Demo'",
"video.languages(['en', 'fi', 'de'])('Demo'"
)
const writes = await apply(
JSON.stringify({
type: 'languagesEdit',
id: 'languages',
languages: ['en', 'de'],
removeLanguages: ['fi'],
}),
source
)
expect(writes[FILE]).toContain("video.languages(['en', 'de'])('Demo'")
})
it('removeLanguages also drops the narration entries of the language', async () => {
const source = SOURCE.replace(
"video('Demo'",
"video.languages(['en', 'fi'])" +
".narration({ en: { intro: 'Hi' }, fi: { intro: 'Moi' } })('Demo'"
)
const writes = await apply(
JSON.stringify({
type: 'languagesEdit',
id: 'languages',
languages: ['en'],
removeLanguages: ['fi'],
}),
source
)
expect(writes[FILE]).toContain(".languages(['en'])")
expect(writes[FILE]).toContain(".narration({ en: { intro: 'Hi' } })")
expect(writes[FILE]).not.toContain('Moi')
})
it('writes an editorMediaEdit as a backend-hosted overlay declaration', async () => {
const writes = await apply(
JSON.stringify({
type: 'editorMediaEdit',
id: 'editorMedia|overlays|logo',
method: 'overlays',
name: 'logo',
editor: 'logo',
})
)
expect(writes[FILE]).toContain(
"video.overlays({ logo: { editor: 'logo' } })('Demo'"
)
})
it('converts a names-only narration declaration instead of refusing', async () => {
const source = SOURCE.replace(
"video('Demo'",
"video.narration(['intro'])('Demo'"
)
const writes = await apply(
JSON.stringify({
type: 'narrationEdit',
id: 'narration|intro|default',
cueName: 'intro',
lang: 'default',
value: 'Hi',
}),
source
)
expect(writes[FILE]).toContain("video.narration({ intro: 'Hi' })('Demo'")
})
})
describe('applyCodegenRequest: formatting', () => {
it('writes the formatted content when a formatFile dep is provided', async () => {
const formatted: string[] = []
const writes = await apply(
JSON.stringify({
type: 'optionsEdit',
id: 'options|renderOptions',
method: 'renderOptions',
values: { fps: 60 },
}),
SOURCE,
async (path, content) => {
formatted.push(path)
return `${content}// formatted\n`
}
)
expect(formatted).toEqual([FILE])
expect(writes[FILE]).toMatch(/\/\/ formatted\n$/)
})
it('does not format when no file changes', async () => {
const source = SOURCE.replace(
"video('Demo'",
"video.renderOptions({ fps: 60 })('Demo'"
)
const formatted: string[] = []
const writes = await apply(
JSON.stringify({
type: 'optionsEdit',
id: 'options|renderOptions',
method: 'renderOptions',
values: { fps: 60 },
}),
source,
async (path, content) => {
formatted.push(path)
return content
}
)
expect(writes).toEqual({})
expect(formatted).toEqual([])
})
})
describe('requireTypescriptForCodegen', () => {
it('throws an actionable error when the loader resolves nothing', () => {
expect(() => requireTypescriptForCodegen(() => null, '/proj')).toThrow(
'TypeScript is not available; install it to enable editor codegen'
)
})
it('returns the loaded module and passes the project dir through', () => {
const seen: string[] = []
const loaded = requireTypescriptForCodegen((dir) => {
seen.push(dir)
return ts
}, '/proj')
expect(loaded).toBe(ts)
expect(seen).toEqual(['/proj'])
})
})
describe('applyCodegenRequest: human-readable edit names in errors', () => {
async function applyRaw(editId: string, editJson: string) {
await applyCodegenRequest(
{
requestId: 'req1',
videoName: 'Demo',
editId,
editJson,
requiresRecord: false,
},
{
ts,
readFile: (path) => (path === FILE ? SOURCE : null),
writeFile: () => {},
editableSnapshot: SNAPSHOT,
}
)
}
it('never shows the raw pipe slug, names the edit instead', async () => {
const error = await applyRaw('options|renderOptions', 'not json').then(
() => null,
(thrown: unknown) => thrown as Error
)
expect(error).not.toBeNull()
expect(error?.message).toContain('render options (renderOptions)')
expect(error?.message).not.toContain('options|renderOptions')
})
it('describes the edit when the payload is not a record', async () => {
await expect(
applyRaw('options|recordOptions', '"just a string"')
).rejects.toThrow(
'Edit for record options (recordOptions) is not an edit record'
)
})
})
describe('applyCodegenRequest: typed refusal reasons in errors', () => {
it('names the reason so the editor toast is actionable', async () => {
const record = JSON.stringify({
type: 'mediaEdit',
id: 'm1',
kind: 'narrationCue',
afterEditId: 'missing-slug',
blocking: true,
props: { name: 'intro' },
})
await expect(apply(record)).rejects.toThrow(/\[unknown-edit-id\]/)
})
})
describe('applyCodegenRequest: orphaned (stale key) soft skip', () => {
async function applyReturning(editJson: string) {
const writes: Record<string, string> = {}
const result = await applyCodegenRequest(
{
requestId: 'req1',
videoName: 'Demo',
editId: 'param|delay',
editJson,
requiresRecord: true,
},
{
ts,
readFile: (path) => (path === FILE ? SOURCE : null),
writeFile: (path, content) => {
writes[path] = content
},
editableSnapshot: SNAPSHOT,
}
)
return { result, writes }
}
// A paramEdit whose target key is absent from the current recording snapshot
// (SNAPSHOT only knows 'fill1'). The action drifted or was removed; there is
// no call site to touch.
const staleDelayEdit = JSON.stringify({
type: 'paramEdit',
id: 'param|delay',
target: { key: 'delay' },
fields: { durationMs: 500 },
})
it('returns { outcome: orphaned } instead of throwing', async () => {
const { result, writes } = await applyReturning(staleDelayEdit)
expect(result).toEqual({ outcome: 'orphaned' })
expect(writes).toEqual({})
})
it('returns { outcome: applied } for a real edit that writes', async () => {
const record = JSON.stringify({
type: 'paramEdit',
id: 'p1',
target: { key: 'fill1' },
fields: { moveDuration: 400 },
})
const result = await applyCodegenRequest(
{
requestId: 'req1',
videoName: 'Demo',
editId: 'param|fill1',
editJson: record,
requiresRecord: true,
},
{
ts,
readFile: (path) => (path === FILE ? SOURCE : null),
writeFile: () => {},
editableSnapshot: SNAPSHOT,
}
)
expect(result).toEqual({ outcome: 'applied' })
})
})
describe('applyCodegenRequest: duplicate editId self-heal', () => {
const DUP_SOURCE = [
"import { video } from 'screenci'",
'',
"video('Demo', async ({ page }) => {",
" await page.locator('#a').fill('A', { editId: 'fill1' })",
" await page.locator('#b').fill('B', { editId: 'fill1' })",
'})',
'',
].join('\n')
const DUP_SNAPSHOT: EditableSnapshot = {
version: 1,
videos: {
Demo: [
{
key: 'fill1',
editId: 'fill1',
locked: false,
defaults: {},
source: { file: FILE, line: 4 },
},
],
},
}
const mediaAfterFill1 = JSON.stringify({
type: 'mediaEdit',
id: 'm1',
kind: 'narrationCue',
afterEditId: 'fill1',
blocking: true,
props: { name: 'intro' },
})
it('re-stamps the duplicate then applies the edit', async () => {
const files: Record<string, string> = { [FILE]: DUP_SOURCE }
await applyCodegenRequest(
{
requestId: 'req1',
videoName: 'Demo',
editId: 'edit1',
editJson: mediaAfterFill1,
requiresRecord: false,
},
{
ts,
readFile: (path) => files[path] ?? null,
writeFile: (path, content) => {
files[path] = content
},
editableSnapshot: DUP_SNAPSHOT,
resolveDuplicateEditIds: async (paths) => {
const plan = planDuplicateEditIdFixes(
paths.map((path) => ({ path, text: files[path]! })),
readEditIdCounters('/proj/.screenci'),
{ ts }
)
for (const file of plan.files) files[file.path] = file.after
return plan.renamed.length > 0
},
}
)
// The duplicate became fill2, the surviving fill1 got the narration cue.
expect(files[FILE]).toContain("editId: 'fill2'")
expect(files[FILE]).toContain('await narration.intro()')
expect(files[FILE]!.match(/editId: 'fill1'/g)).toHaveLength(1)
})
it('still throws when the duplicate cannot be resolved', async () => {
const files: Record<string, string> = { [FILE]: DUP_SOURCE }
await expect(
applyCodegenRequest(
{
requestId: 'req1',
videoName: 'Demo',
editId: 'edit1',
editJson: mediaAfterFill1,
requiresRecord: false,
},
{
ts,
readFile: (path) => files[path] ?? null,
writeFile: () => {},
editableSnapshot: DUP_SNAPSHOT,
resolveDuplicateEditIds: async () => false,
}
)
).rejects.toThrow(/\[ambiguous-edit-id\]/)
})
})
describe('applyCodegenRequest: onSourcesRewritten', () => {
const OPTIONS_EDIT = JSON.stringify({
type: 'optionsEdit',
id: 'options|renderOptions',
method: 'renderOptions',
values: { fps: 60 },
})
async function applyWithHook(params: {
editJson: string
requiresRecord: boolean
source?: string
}) {
const rewritten: string[][] = []
const recordRequired: string[][] = []
await applyCodegenRequest(
{
requestId: 'req1',
videoName: 'Demo',
editId: 'edit1',
editJson: params.editJson,
requiresRecord: params.requiresRecord,
},
{
ts,
readFile: (path) => (path === FILE ? (params.source ?? SOURCE) : null),
writeFile: () => {},
editableSnapshot: SNAPSHOT,
onSourcesRewritten: async (paths) => {
rewritten.push(paths)
},
onRecordRequiredRewrite: (paths) => {
recordRequired.push(paths)
},
}
)
return { rewritten, recordRequired }
}
it('reports the written paths for a render-time edit', async () => {
const { rewritten } = await applyWithHook({
editJson: OPTIONS_EDIT,
requiresRecord: false,
})
expect(rewritten).toEqual([[FILE]])
})
it('is not called for a record-requiring edit, which reports separately', async () => {
const { rewritten, recordRequired } = await applyWithHook({
editJson: OPTIONS_EDIT,
requiresRecord: true,
})
expect(rewritten).toEqual([])
// The record-requiring hook fires instead, so the caller can keep the
// file out of later render-time re-baselines until a record runs.
expect(recordRequired).toEqual([[FILE]])
})
it('is not called when the edit changes nothing', async () => {
const { rewritten } = await applyWithHook({
editJson: OPTIONS_EDIT,
requiresRecord: false,
source: SOURCE.replace(
"video('Demo'",
"video.renderOptions({ fps: 60 })('Demo'"
),
})
expect(rewritten).toEqual([])
})
})
-224
View File
@@ -1,224 +0,0 @@
/**
* 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,
* 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
* reports the request failed and the editor reverts the optimistic value.
*
* Returns `{ outcome: 'orphaned' }` (instead of throwing) when the only reason
* the edit could not be applied is that its target is absent from the current
* recording snapshot: a stale override key, which the listener reports as a
* soft skip so the request is auto-discarded rather than surfaced as a failure.
*/
import { planCodeSync } from './codeSync.js'
import type { TsModule } from './codemod.js'
import type { DevCodegenRequest } from './devListen.js'
import {
splitTimelineEditsByVideo,
type EditableSnapshot,
} from './editableSnapshot.js'
import { describeEditId } from './timelineEdits.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
/**
* Optional formatter applied to each changed file's content before it is
* written (see src/format.ts). Must never throw; on trouble it returns the
* content unchanged.
*/
formatFile?: (path: string, content: string) => Promise<string>
/**
* Optional self-heal for duplicate editIds. Given candidate source paths, it
* re-stamps any slug that appears at more than one call site with a fresh
* slug, writes the fixes, and returns true when it changed anything. Called
* before failing on an `ambiguous-edit-id`, so the edit can then reapply.
*/
resolveDuplicateEditIds?: (paths: string[]) => Promise<boolean>
/**
* Optional lister of the project's recording source files, forwarded to
* `planCodeSync` as a last-resort way to locate a video's builder
* declaration when the editable snapshot carries no `source.file` for it
* (the video was edited before it was cleanly recorded). See
* `CodeSyncDeps.listRecordingFiles`.
*/
listRecordingFiles?: () => string[]
/**
* Optional hook invoked after the CLI wrote source files for an edit that
* applies at render time (`requiresRecord: false`), with the paths actually
* rewritten. Used to re-baseline kept recordings' source hashes so applying
* a web edit never marks recordings stale (and never causes a re-record).
* Not invoked for `requiresRecord: true` edits or for the duplicate-editId
* self-heal writes: those are real semantic changes that need fresh footage.
*/
onSourcesRewritten?: (changedPaths: string[]) => Promise<void>
/**
* Optional hook invoked after the CLI wrote source files for an edit that
* REQUIRES a record (`requiresRecord: true`), with the paths actually
* rewritten. Callers use it to protect those files from a later render-time
* edit's re-baseline in the same session: without it, a narration edit
* applied right after a languages edit re-hashed the whole file and erased
* the record-requiring change's staleness, so the needed record never ran.
*/
onRecordRequiredRewrite?: (changedPaths: string[]) => void
}
/**
* Guard for the codegen path: resolve the TypeScript module through the
* injected loader, throwing an actionable error when it is unavailable (the
* dev listener reports the message back to the editor).
*/
export function requireTypescriptForCodegen(
loadTs: (projectDir: string) => TsModule | null,
projectDir: string
): TsModule {
const ts = loadTs(projectDir)
if (ts === null) {
throw new Error(
'TypeScript is not available; install it to enable editor codegen'
)
}
return ts
}
export type ApplyCodegenOutcome = { outcome: 'applied' | 'orphaned' }
export async function applyCodegenRequest(
request: DevCodegenRequest,
deps: ApplyCodegenDeps
): Promise<ApplyCodegenOutcome> {
let record: unknown
try {
record = JSON.parse(request.editJson)
} catch {
throw new Error(
`Edit for ${describeEditId(request.editId)} carries invalid JSON`
)
}
if (typeof record !== 'object' || record === null) {
throw new Error(
`Edit for ${describeEditId(request.editId)} is not an edit record`
)
}
const split = splitTimelineEditsByVideo({
[request.videoName]: { version: 4, edits: [record] },
})
const editorOptionsVideo = split.studioOptions[request.videoName]
const computePlan = () =>
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,
...(editorOptionsVideo !== undefined && {
editorOptionsSync: {
videos: {
[request.videoName]: {
...editorOptionsVideo,
content: {
narration: false,
text: false,
audio: false,
assets: false,
},
},
},
},
}),
narrationEdits: split.narrationEdits,
valuesEdits: split.valuesEdits,
languagesEdits: split.languagesEdits,
editorMediaEdits: split.editorMediaEdits,
},
{
ts: deps.ts,
readFile: deps.readFile,
...(deps.listRecordingFiles !== undefined && {
listRecordingFiles: deps.listRecordingFiles,
}),
}
)
let plan = computePlan()
// A duplicate editId (one slug at two distinct call sites) makes the target
// ambiguous. Self-heal by re-stamping the duplicates, then reapply once.
if (
plan.unappliable.length > 0 &&
plan.unappliable.every((item) => item.reason === 'ambiguous-edit-id') &&
deps.resolveDuplicateEditIds !== undefined
) {
const paths = [
...new Set(
Object.values(deps.editableSnapshot.videos)
.flat()
.map((entry) => entry.source?.file)
.filter((file): file is string => file !== undefined)
),
]
if (await deps.resolveDuplicateEditIds(paths)) {
plan = computePlan()
}
}
if (plan.unappliable.length > 0) {
// A stale override key (its action left the recording) is not a failure:
// there is nothing to write and nothing the user did wrong. When every
// refusal is that soft kind, report it as orphaned so the request is
// auto-discarded. Any other refusal in the mix is still a hard failure.
const hard = plan.unappliable.filter(
(item) => item.reason !== 'orphaned-override'
)
if (hard.length === 0) {
return { outcome: 'orphaned' }
}
const reasons = hard
.map((item) => `[${item.reason}] ${item.message}`)
.join('; ')
throw new Error(
`Edit for ${describeEditId(request.editId)} could not be applied to code: ${reasons}`
)
}
const writtenPaths: string[] = []
for (const file of plan.files) {
if (file.after === file.before) continue
const content =
deps.formatFile !== undefined
? await deps.formatFile(file.path, file.after)
: file.after
deps.writeFile(file.path, content)
writtenPaths.push(file.path)
}
if (request.requiresRecord && writtenPaths.length > 0) {
deps.onRecordRequiredRewrite?.(writtenPaths)
}
if (
!request.requiresRecord &&
writtenPaths.length > 0 &&
deps.onSourcesRewritten !== undefined
) {
await deps.onSourcesRewritten(writtenPaths)
}
return { outcome: 'applied' }
}
-2469
View File
File diff suppressed because it is too large Load Diff
-2384
View File
File diff suppressed because it is too large Load Diff
-93
View File
@@ -1,93 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
createCodegenFailureLog,
formatCodegenFailureLine,
} from './codegenFailureLog.js'
const UNKNOWN_VIDEO_MESSAGE = (cue: string) =>
`Edit for the "${cue}" narration (et) could not be applied to code: ` +
`[unknown-video] locked narration cue '${cue}' on video 'Old Name': the ` +
`builder declaration of video 'Old Name' was not found in exactly one source file`
describe('formatCodegenFailureLine', () => {
it('turns an unknown-video refusal into one actionable line', () => {
const line = formatCodegenFailureLine({
videoName: 'Old Name',
editDescription: 'the "intro" narration (et)',
message: UNKNOWN_VIDEO_MESSAGE('intro'),
})
expect(line).toContain('Could not apply edits to "Old Name"')
expect(line).toContain('renamed or removed in code')
expect(line).not.toContain('locked narration cue')
})
it('keeps the raw message for other failures', () => {
const line = formatCodegenFailureLine({
videoName: 'Video',
editDescription: 'the "intro" narration (et)',
message: 'boom',
})
expect(line).toBe(
'Codegen for the "intro" narration (et) (Video) failed: boom'
)
})
})
describe('createCodegenFailureLog', () => {
function make() {
const lines: string[] = []
const flushes: (() => void)[] = []
const log = createCodegenFailureLog((line) => lines.push(line), {
schedule: (fn) => flushes.push(fn),
})
return { log, lines, flush: () => flushes.splice(0).forEach((fn) => fn()) }
}
it('collapses a burst of identical-cause failures into one line plus a count', () => {
const { log, lines, flush } = make()
for (const cue of ['intro', 'openSearch', 'typeQuery', 'pickResult']) {
log.logFailure({
videoName: 'Old Name',
editDescription: `the "${cue}" narration (et)`,
message: UNKNOWN_VIDEO_MESSAGE(cue),
})
}
expect(lines).toHaveLength(1)
flush()
expect(lines).toHaveLength(2)
expect(lines[1]).toBe(
'(3 more edits for "Old Name" failed for the same reason.)'
)
})
it('logs distinct causes and videos separately', () => {
const { log, lines, flush } = make()
log.logFailure({
videoName: 'A',
editDescription: 'the language set',
message: UNKNOWN_VIDEO_MESSAGE('intro'),
})
log.logFailure({
videoName: 'B',
editDescription: 'the "intro" narration (et)',
message: 'boom',
})
expect(lines).toHaveLength(2)
flush()
// No counts: each burst had a single failure.
expect(lines).toHaveLength(2)
})
it('starts a fresh burst after the window flushed', () => {
const { log, lines, flush } = make()
const input = {
videoName: 'A',
editDescription: 'the "intro" narration (et)',
message: UNKNOWN_VIDEO_MESSAGE('intro'),
}
log.logFailure(input)
flush()
log.logFailure(input)
expect(lines).toHaveLength(2)
})
})
-94
View File
@@ -1,94 +0,0 @@
/**
* Coalesced logging for codegen-apply failures.
*
* A single web action can queue many edits that all fail for the same root
* cause (adding a language queues one narration edit per cue; a missing
* `video(...)` declaration fails every one of them identically). Logging each
* failure verbatim produced a wall of near-identical lines. This logger
* prints ONE concise line per (video, cause) burst and a short "(N more ...)"
* follow-up once the burst settles.
*/
export type CodegenFailureLog = {
/** Report one failed edit; identical-cause failures within the coalescing
* window collapse into the first line plus a count. */
logFailure: (input: {
videoName: string
/** Human description of the edit, e.g. `the "intro" narration (et)`. */
editDescription: string
/** The raw apply error message (may carry a `[reason-code]` marker). */
message: string
}) => void
}
/** How long a burst may keep adding identical failures to one line. */
const COALESCE_MS = 3000
/** The `[reason-code]` marker embedded in apply refusals, used as the
* coalescing key so per-cue detail differences still collapse. */
function reasonCodeOf(message: string): string {
return /\[[a-z-]+\]/.exec(message)?.[0] ?? message
}
/**
* One concise line for the burst's first failure. The unknown-video refusal
* (a declaration the project no longer has, usually a rename in code) gets a
* dedicated actionable message; everything else keeps the raw error.
*/
export function formatCodegenFailureLine(input: {
videoName: string
editDescription: string
message: string
}): string {
if (input.message.includes('[unknown-video]')) {
return (
`Could not apply edits to "${input.videoName}": no video with that ` +
`name is declared in this project (was it renamed or removed in ` +
`code?). The edits are marked failed in the editor.`
)
}
return `Codegen for ${input.editDescription} (${input.videoName}) failed: ${input.message}`
}
export function createCodegenFailureLog(
error: (line: string) => void,
options: {
coalesceMs?: number
/** Injectable timer for tests. */
schedule?: (fn: () => void, ms: number) => void
} = {}
): CodegenFailureLog {
const coalesceMs = options.coalesceMs ?? COALESCE_MS
const schedule =
options.schedule ??
((fn: () => void, ms: number) => {
const timer = setTimeout(fn, ms)
// Never keep the CLI process alive just for a pending summary line.
;(timer as { unref?: () => void }).unref?.()
})
/** key -> number of additional identical failures suppressed this burst. */
const suppressed = new Map<string, number>()
return {
logFailure(input) {
const key = `${input.videoName}|${reasonCodeOf(input.message)}`
const count = suppressed.get(key)
if (count !== undefined) {
suppressed.set(key, count + 1)
return
}
suppressed.set(key, 0)
schedule(() => {
const more = suppressed.get(key) ?? 0
suppressed.delete(key)
if (more > 0) {
error(
`(${more} more edit${more === 1 ? '' : 's'} for ` +
`"${input.videoName}" failed for the same reason.)`
)
}
}, coalesceMs)
error(formatCodegenFailureLine(input))
},
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Static-analysis primitives for `screenci sync`: surgical text edits to
* Static-analysis primitives for source codemods: surgical text edits to
* .screenci.ts files, computed with the TypeScript compiler API used purely as
* a parser. Only the edited ranges change; untouched text is never reprinted,
* so the user's formatting and comments survive exactly.
+10 -633
View File
@@ -1,161 +1,31 @@
/**
* Poll loop behind `screenci edit`: registers this machine as a dev listener,
* 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).
*
* 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).
* Dev-channel registration behind `screenci preview`: registers this machine
* as a dev listener for the run, reports which videos it is bringing up to
* date (the editor locks those videos' timelines until the list clears), and
* deregisters on exit. All side effects (fetch, sleeping) are injected so the
* helpers are unit-testable.
*/
import { describeEditId } from './timelineEdits.js'
import {
createCodegenFailureLog,
type CodegenFailureLog,
} from './codegenFailureLog.js'
import { formatAppliedEditLine } from './previewOutput.js'
export const DEV_TOKEN_HEADER = 'X-ScreenCI-Dev-Token'
export const SCREENCI_EDIT_TOKEN_ENV = 'SCREENCI_EDIT_TOKEN'
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 edit
* --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
videoName: string
language: string
requestedByName: string
/** Record raw footage into the preview slot without dispatching a render. */
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
/** The org member who authored the edit. Present for deferred edits queued
* while no machine was connected; used only for the apply log line. */
queuedBy?: string
}
export type DevPollResult = {
trigger: DevTrigger | null
codegenRequests: DevCodegenRequest[]
}
export type DevListenLogger = {
info: (message: string) => void
warn: (message: string) => void
error: (message: string) => void
}
/** One coalescing failure log per logger, so identical-cause codegen failures
* across a drain burst collapse into a single line (see codegenFailureLog). */
const codegenFailureLogs = new WeakMap<DevListenLogger, CodegenFailureLog>()
function codegenFailureLogFor(logger: DevListenLogger): CodegenFailureLog {
let log = codegenFailureLogs.get(logger)
if (log === undefined) {
log = createCodegenFailureLog((line) => logger.error(line))
codegenFailureLogs.set(logger, log)
}
return log
}
/**
* 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<void>
logger: DevListenLogger
/**
* 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.
* Resolves with `{ outcome: 'orphaned' }` when the edit targets an action no
* longer in the recording (a stale key), so the listener soft-skips it.
*/
applyCodegen?: (
request: DevCodegenRequest
) => Promise<{ outcome: 'applied' | 'orphaned' } | void>
/**
* 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<void>
/**
* Whether an unknown-video codegen failure (an edit addressed to a video
* name no longer declared in the project) should be logged. Wired to the
* session's grep filter: without a grep these lines stay silent, with a
* grep only matching video names are logged. The edit is still reported
* failed to the backend either way. Defaults to logging.
*/
shouldLogUnknownVideo?: (videoName: string) => boolean
/**
* Whether each successfully applied codegen request logs its own line.
* Defaults to true (live `--watch` sessions want per-edit feedback).
* One-shot commands set false and print only the drain summary; skipped
* and failed edits always log regardless.
*/
logAppliedEdits?: boolean
/** 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 = {
apiUrl: string
/**
* The org credential every dev call authenticates with: a real org secret
* (X-ScreenCI-Secret) or, for an account-less `screenci edit`, the anon
* (X-ScreenCI-Secret) or, for an account-less `screenci preview`, the anon
* session token (X-ScreenCI-Anon-Token, resolved to the trial org's secret
* by the backend proxy). See src/anonSession.ts CliCredential.
*/
@@ -163,22 +33,11 @@ export type DevListenConfig = {
devToken: string
projectName: string
machineName: string
pollIntervalMs?: number
errorBackoffMs?: number
runHeartbeatMs?: number
fastPollIntervalMs?: number
fastPollWindowMs?: number
recordKillWindowMs?: number
}
/** Thrown when the backend rejects our credentials; the loop must stop. */
/** Thrown when the backend rejects our credentials; the caller must stop. */
export class DevAuthError extends Error {}
function defaultSetInterval(fn: () => void, ms: number): () => void {
const handle = setInterval(fn, ms)
return () => clearInterval(handle)
}
async function postDev<T>(
config: DevListenConfig,
deps: Pick<DevListenDeps, 'fetchFn'>,
@@ -207,9 +66,9 @@ async function postDev<T>(
}
// Tolerate an empty 2xx body: the /cli/dev/* proxy (and idle keep-alives) can
// return an empty response, and calling res.json() on it throws "Unexpected
// end of JSON input", which the poll loop would otherwise log as a connection
// problem and back off on. An empty body just means "nothing to report" (e.g.
// no pending trigger), so resolve to an empty object.
// end of JSON input", which callers would otherwise log as a connection
// problem. An empty body just means "nothing to report", so resolve to an
// empty object.
const text = await res.text()
if (text.trim() === '') return {} as T
try {
@@ -230,53 +89,6 @@ export async function registerDevListener(
})
}
export async function pollDevListener(
config: DevListenConfig,
deps: DevListenDeps,
listenerId: string
): 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(
config: DevListenConfig,
deps: DevListenDeps,
listenerId: string,
triggerId: string,
state: 'running' | 'done' | 'failed',
errorMessage?: string
): Promise<void> {
await postDev(config, deps, '/cli/dev/report', {
listenerId,
triggerId,
state,
...(errorMessage !== undefined ? { errorMessage } : {}),
})
}
export async function reportDevCodegen(
config: DevListenConfig,
deps: DevListenDeps,
listenerId: string,
requestId: string,
state: 'applied' | 'failed' | 'orphaned',
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
@@ -301,438 +113,3 @@ export async function deregisterDevListener(
): Promise<void> {
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 = {
job: RecordJob
startedAt: number
abort: AbortController
/** Set before aborting so the completion reports "superseded", not "done". */
superseded: boolean
done: Promise<void>
}
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,
listenerId: string,
request: DevCodegenRequest
): Promise<'applied' | 'orphaned' | 'failed'> {
if (deps.applyCodegen === undefined) {
await reportDevCodegen(
config,
deps,
listenerId,
request.requestId,
'failed',
'This listener does not support codegen'
)
return 'failed'
}
try {
const result = await deps.applyCodegen(request)
if (result != null && result.outcome === 'orphaned') {
// The edit targeted an action no longer in the recording (a stale key).
// Nothing to write; report a soft skip so the request is auto-discarded
// instead of surfacing as a failure the user has to clear.
await reportDevCodegen(
config,
deps,
listenerId,
request.requestId,
'orphaned'
)
deps.logger.info(
`Skipped ${describeEditId(request.editId)} on "${request.videoName}": ` +
`its action is no longer in the recording.`
)
return 'orphaned'
}
await reportDevCodegen(
config,
deps,
listenerId,
request.requestId,
'applied'
)
if (deps.logAppliedEdits !== false) {
deps.logger.info(
formatAppliedEditLine({
editDescription: describeEditId(request.editId),
videoName: request.videoName,
queuedBy: request.queuedBy,
requiresRecord: request.requiresRecord,
})
)
}
return 'applied'
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
// Unknown-video refusals (usually a rename in code) are only worth a log
// line when the session's grep specifically targets that video; the
// failure still reaches the backend below either way.
const suppress =
message.includes('[unknown-video]') &&
deps.shouldLogUnknownVideo !== undefined &&
!deps.shouldLogUnknownVideo(request.videoName)
if (!suppress) {
// Coalesced: a burst of identical-cause failures (e.g. every cue of an
// added language hitting the same missing declaration) logs one line.
codegenFailureLogFor(deps.logger).logFailure({
videoName: request.videoName,
editDescription: describeEditId(request.editId),
message,
})
}
await reportDevCodegen(
config,
deps,
listenerId,
request.requestId,
'failed',
message
)
return 'failed'
}
}
export const ONE_SHOT_TRIGGER_MESSAGE =
'This machine connected briefly to sync edits. Start `screenci edit --watch` for live record requests.'
/**
* Drains every queued (deferred) codegen request for this listener in one
* pass: polls, applies each returned request, and repeats until a poll comes
* back empty. Used by one-shot commands (`edit` without --watch, `sync`, and
* the pre-run sync in `test`/`export`) so browser edits reach the sources at
* every CLI touchpoint without a long-running bridge. A record trigger
* claimed during the drain is failed with a pointer to `edit --watch`, since
* nothing will be around to run it.
*/
export async function drainDevCodegenRequests(
config: DevListenConfig,
deps: DevListenDeps,
listenerId: string
): Promise<{ handled: number; failed: number; skipped: number }> {
// handled counts edits actually written into the sources; orphaned edits
// (their action is gone from the recording, logged as skipped) and failures
// are counted separately so the summary never overstates what synced.
let handled = 0
let failed = 0
let skipped = 0
for (;;) {
const result = await pollDevListener(config, deps, listenerId)
for (const request of result.codegenRequests) {
const outcome = await handleCodegenRequest(
config,
deps,
listenerId,
request
)
if (outcome === 'failed') failed++
else if (outcome === 'orphaned') skipped++
else handled++
}
if (result.trigger !== null) {
await reportDevTrigger(
config,
deps,
listenerId,
result.trigger.triggerId,
'failed',
ONE_SHOT_TRIGGER_MESSAGE
).catch(() => {})
}
if (result.codegenRequests.length === 0 && result.trigger === null) {
return { handled, failed, skipped }
}
}
}
/**
* Read-only count of editor edits queued for this project that are not yet
* written into any source. Unlike a poll, this claims nothing: `screenci
* export` uses it to warn about unsynced edits without applying them.
*/
export async function fetchPendingCodegenCount(
config: DevListenConfig,
deps: Pick<DevListenDeps, 'fetchFn'>
): Promise<number> {
const result = await postDev<{ pending?: number }>(
config,
deps,
'/cli/dev/pending-codegen-count',
{}
)
return typeof result.pending === 'number' ? result.pending : 0
}
export type DevListenController = {
stop: () => void
}
/**
* Runs the listener loop until `controller.stop()` is called or the backend
* rejects our credentials (DevAuthError propagates to the caller). Network
* hiccups are logged and retried with a longer backoff.
*/
export async function runDevListenLoop(
config: DevListenConfig,
deps: DevListenDeps,
listenerId: string,
controller: { stopped: boolean }
): 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: RecordJob | null = null
let lastActivityAt = -Infinity
const runTriggerJob = async (
slot: ActiveRecord,
trigger: DevTrigger,
abort: AbortController
): Promise<void> => {
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<void> => {
// 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 = {
job,
startedAt: now(),
abort,
superseded: false,
done: Promise.resolve(),
}
active = slot
slot.done = (async () => {
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)}`)
}
}
if (active === slot) active = null
const next = queued
queued = null
if (next !== null && !controller.stopped) startRecord(next)
})()
}
const acceptJob = async (job: RecordJob): Promise<void> => {
if (active === null) {
startRecord(job)
return
}
if (queued !== null) {
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 = job
if (now() - active.startedAt < killWindowMs) {
// Young run: kill it, the slot's completion starts the queued job.
deps.logger.info(
`Killing the record of ${jobLabel(active.job)} for a newer request.`
)
active.superseded = true
active.abort.abort()
} else {
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) {
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 acceptJob({ kind: 'trigger', trigger: result.trigger })
}
} catch (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) 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(() => {})
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* `screenci edit` startup handshake.
* `screenci preview` 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
-261
View File
@@ -1,261 +0,0 @@
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<void>
hashes: Map<string, string>
onSourcesChanged: ReturnType<typeof vi.fn>
onConfigChanged: ReturnType<typeof vi.fn>
watchedDirs: () => string[]
cancelledDirs: () => string[]
}
function harness(initialHashes: Record<string, string>): Harness {
const hashes = new Map(Object.entries(initialHashes))
const listeners = new Map<string, (fileBasename: string) => 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<string, string[]>,
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'])
})
})
-225
View File
@@ -1,225 +0,0 @@
/**
* Source-file watcher behind `screenci edit`: 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<string, Set<string>>
/** 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<string, Set<string>>()
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<string>()
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<string | undefined>
/** 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<void>
/**
* 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<void>
}
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<DevWatcherController> {
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<string, string | undefined>()
const dirWatchers = new Map<string, () => void>()
const pending = new Set<string>()
let cancelFlush: (() => void) | null = null
const watchedPaths = (): string[] => [
...current.files.keys(),
...(current.configPath !== null ? [current.configPath] : []),
]
const flush = async (): Promise<void> => {
cancelFlush = null
const changed = [...pending]
pending.clear()
const videoNames = new Set<string>()
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<void> => {
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)
},
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* editId allocation and stamping for `screenci sync` / `screenci edit`.
* editId allocation and stamping for the `screenci preview` startup handshake.
*
* Every editable action can carry a stable, human-readable identity slug in
* code (`editId: 'fill1'`). This module assigns slugs to actions recorded
+2 -2
View File
@@ -36,7 +36,7 @@ export type EditableActionDescriptor = {
subKind?: string
/**
* Stable, human-readable identity slug from code (e.g. `fill1`, `autoZoom2`),
* set via the `editId` option and stamped automatically by `screenci sync`.
* set via the `editId` option and stamped automatically at startup.
* When present it IS the action's stable key: matching survives refactors,
* moved lines, and locator changes. The matcher-based identity below is the
* bootstrap fallback for not-yet-stamped actions.
@@ -57,7 +57,7 @@ export type EditableActionDescriptor = {
/**
* The user-code call site that produced the action, captured from the
* stack at instrumentation time. Not part of the stable identity; used by
* `screenci sync` and `screenci status` to point at where a change lands.
* tooling to point at where a change lands.
*/
source?: { file: string; line: number }
}
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* 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 edit` (see applyCodegen.ts), so a recording always runs
* sources by editor edits, so a recording always runs
* with the code-declared values; nothing is overridden at record time.
*/
import type { EditableMeta } from './editableDescriptor.js'
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Editable-action entries collected from the per-recording `data.json` files
* (stable key, editId, effective defaults, call-site source). Editor codegen
* (`screenci edit`) uses them to locate call sites by editId when writing an
* 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, statSync } from 'fs'
-23
View File
@@ -1,23 +0,0 @@
/**
* Editor option state fetched from the web editor: render options, record
* options, and presence flags for content edits (narration, on-screen text,
* audio, overlays). Code is the source of truth: `screenci sync` codifies these
* into `video.renderOptions(...)` / `video.recordOptions(...)` calls. Render
* options apply server-side, record options at record time. Pure state type;
* the CLI wires in the fetch.
*/
export interface EditorOptionsSyncVideo {
renderOptions?: Record<string, unknown>
recordOptions?: Record<string, unknown>
content: {
narration: boolean
text: boolean
audio: boolean
assets: boolean
}
}
export interface EditorOptionsSyncState {
videos: Record<string, EditorOptionsSyncVideo>
}
+1 -1
View File
@@ -1211,7 +1211,7 @@ export type RecordingMetadata = {
sourceFilePath?: string
/**
* SHA-256 of the test source file this recording was produced from. Together
* with per-event editIds it lets `screenci edit` skip re-recording when the
* with per-event editIds it lets `screenci preview` skip re-recording when the
* source is unchanged (see recordingFreshness.ts). Multiple videos from the
* same file share the hash.
*/
+5 -8
View File
@@ -575,7 +575,7 @@ function generateIslandPackageJson(projectName: string): string {
type: 'module',
scripts: {
test: 'screenci test',
edit: 'screenci edit',
preview: 'screenci preview',
export: 'screenci export',
format: 'prettier --write .',
},
@@ -1193,7 +1193,7 @@ export async function setUpInitSecret(
const envPath = resolve(islandDir, '.env')
// One-and-done connect: with a secret on hand, also mint this machine's
// personal editor token now so `screenci edit` never has to ask for one.
// personal editor token now so `screenci preview` never has to ask for one.
// Best-effort: `edit` retries the exchange itself when this fails.
const mintEditToken = async (secret: string): Promise<void> => {
if (env.SCREENCI_EDIT_TOKEN) return
@@ -1395,9 +1395,6 @@ ${appBuildHint} - name: Install dependencies
working-directory: ${islandWorkflowPath}
run: ${commands.playwrightRun} install --only-shell chromium
# --no-sync keeps the CI checkout read-only: queued browser edits are
# not pulled into the sources here, they stay queued for your next
# local preview or sync.
- id: record
name: Record previews
working-directory: ${islandWorkflowPath}
@@ -1406,9 +1403,9 @@ ${appBuildHint} - name: Install dependencies
SCREENCI_GREP: \${{ inputs.grep }}
run: |
if [ -n "$SCREENCI_GREP" ]; then
${commands.screenciRun} preview --no-sync --grep "$SCREENCI_GREP"
${commands.screenciRun} preview --grep "$SCREENCI_GREP"
else
${commands.screenciRun} preview --no-sync
${commands.screenciRun} preview
fi
# Prefer final rendered videos over live previews? Replace the run
@@ -1444,7 +1441,7 @@ video
// Open with a brief brand intro card before the walkthrough begins.
await overlays.logo.for(2000)
// Play the narration line for this step.
// Play the narration for this part of the flow.
await narration.docs()
// Automatically zoom into interactions so they are easier to follow.
+1 -67
View File
@@ -3,67 +3,9 @@ import { describe, expect, it } from 'vitest'
import {
baseVideoName,
dedupeAppliedStudioNotices,
formatAppliedEditLine,
formatDrainSummary,
formatStudioNoticeLine,
} from './previewOutput.js'
describe('formatDrainSummary', () => {
it('is null when nothing was handled and nothing failed', () => {
expect(formatDrainSummary({ handled: 0, failed: 0 })).toBeNull()
})
it('pluralizes the synced count', () => {
expect(formatDrainSummary({ handled: 1, failed: 0 })).toBe(
'Synced 1 editor edit into your sources.'
)
expect(formatDrainSummary({ handled: 3, failed: 0 })).toBe(
'Synced 3 editor edits into your sources.'
)
})
it('names failures alongside the synced count', () => {
expect(formatDrainSummary({ handled: 0, failed: 1 })).toBe(
'Synced 0 editor edits; 1 edit could not be applied.'
)
expect(formatDrainSummary({ handled: 2, failed: 2 })).toBe(
'Synced 2 editor edits; 2 edits could not be applied.'
)
})
})
describe('formatAppliedEditLine', () => {
const base = {
editDescription: 'the "intro" narration (en)',
videoName: 'Demo',
requiresRecord: true,
}
it('names a known author', () => {
expect(formatAppliedEditLine({ ...base, queuedBy: 'Olli' })).toBe(
'Applied the "intro" narration (en) to "Demo" (queued by Olli).'
)
})
it('omits the author when unknown, missing, or empty', () => {
expect(formatAppliedEditLine(base)).toBe(
'Applied the "intro" narration (en) to "Demo".'
)
expect(formatAppliedEditLine({ ...base, queuedBy: 'Unknown user' })).toBe(
'Applied the "intro" narration (en) to "Demo".'
)
expect(formatAppliedEditLine({ ...base, queuedBy: ' ' })).toBe(
'Applied the "intro" narration (en) to "Demo".'
)
})
it('adds the render-time trailer for edits that need no re-record', () => {
expect(formatAppliedEditLine({ ...base, requiresRecord: false })).toBe(
'Applied the "intro" narration (en) to "Demo". Applies at render time, no re-record needed.'
)
})
})
describe('baseVideoName', () => {
it('strips a trailing language suffix', () => {
expect(baseVideoName('Demo [en]')).toBe('Demo')
@@ -108,15 +50,7 @@ describe('formatStudioNoticeLine', () => {
describe('wording rules', () => {
it('never uses an em-dash', () => {
const lines = [
formatDrainSummary({ handled: 2, failed: 1 }),
formatAppliedEditLine({
editDescription: 'x',
videoName: 'y',
requiresRecord: false,
}),
formatStudioNoticeLine('Demo'),
]
const lines = [formatStudioNoticeLine('Demo')]
for (const line of lines) {
expect(line).not.toContain('—')
}
+3 -45
View File
@@ -1,51 +1,9 @@
/**
* Pure formatting helpers for the user-facing output of `screenci preview`
* and `screenci sync`. Kept side-effect free so every line the user reads is
* unit-testable: the drain summary, the per-edit apply line (--watch), and
* the post-upload editor-settings notice.
* Pure formatting helpers for the user-facing output of `screenci preview`.
* Kept side-effect free so every line the user reads is unit-testable: the
* post-upload editor-settings notice.
*/
/** The backend's fallback author name for identity-less or anonymous edits. */
const UNKNOWN_QUEUED_BY = 'Unknown user'
/**
* One-line summary of an edit drain. Null when there is nothing to report
* (no edits handled and none failed).
*/
export function formatDrainSummary(counts: {
handled: number
failed: number
}): string | null {
const { handled, failed } = counts
if (handled === 0 && failed === 0) return null
const synced = `Synced ${handled} editor edit${handled === 1 ? '' : 's'}`
if (failed === 0) return `${synced} into your sources.`
return `${synced}; ${failed} edit${failed === 1 ? '' : 's'} could not be applied.`
}
/**
* The per-edit apply line printed by live (`--watch`) sessions. The queued-by
* suffix is omitted when the author is unknown (missing, empty, or the
* backend's "Unknown user" fallback): naming an unknown author only confuses.
*/
export function formatAppliedEditLine(input: {
editDescription: string
videoName: string
queuedBy?: string | undefined
requiresRecord: boolean
}): string {
const queuedBy =
input.queuedBy !== undefined &&
input.queuedBy.trim() !== '' &&
input.queuedBy !== UNKNOWN_QUEUED_BY
? ` (queued by ${input.queuedBy})`
: ''
const trailer = input.requiresRecord
? ''
: ' Applies at render time, no re-record needed.'
return `Applied ${input.editDescription} to "${input.videoName}"${queuedBy}.${trailer}`
}
/** Strips a trailing per-language suffix (` [en]`, ` [pt-br]`) from an
* uploaded pass's display name, yielding the video's base name. */
export function baseVideoName(name: string): string {
+1 -1
View File
@@ -10,7 +10,7 @@ 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 edit` session can decide whether a recording needs to be
* next `screenci preview` run 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
+7 -7
View File
@@ -8,7 +8,7 @@
* sleeps.
*
* Code is the single source of truth: edits arrive over the dev channel as
* codegen requests (`screenci edit`, see applyCodegen.ts) and are written
* editor edits and are applied
* straight into the .screenci.ts sources. Nothing is applied at record time;
* a recording always runs purely from code values.
*
@@ -74,7 +74,7 @@ export type ParamEdit = {
/**
* Rename of an action's stable `editId` slug, made in the web editor. Applied
* to code by `screenci sync` (the slug's string literal is replaced); until
* by the editor (the slug is the stable identity); until
* then the recorded slug keeps matching, so nothing goes stale in between.
*/
export type RenameEdit = {
@@ -186,7 +186,7 @@ export type GapPointEdit = {
/**
* Removes a code-owned NAMED wrapper block (`hide('name', ...)` /
* `speed('name', ...)` / `time('name', ...)`) from source. `screenci sync`
* `speed('name', ...)` / `time('name', ...)`) from source. The editor
* unwraps the block, keeping the wrapped calls (any `waitForTimeout` pacing
* inside the block survives as plain gap sleeps). `target.editId` is the
* block's stable name slug. Anonymous blocks cannot be targeted.
@@ -200,7 +200,7 @@ export type BlockRemoveEdit = {
/**
* Removes a recorded INTERACTION call (a Playwright action like `click`/`fill`)
* from source, identified by its stable `editId` slug. `screenci sync` deletes
* from source, identified by its stable `editId` slug. The editor treats
* the call statement and coalesces the `waitForTimeout` sleeps that surrounded
* it into a single gap, so everything after it shifts left (the recording just
* jumps forward past the removed step). `requiresRecord` is always true: the
@@ -224,7 +224,7 @@ export type InteractionRemoveEdit = {
* variant to merge into the named overlay's config object:
* - `{ margin }` for a locator-locked overlay (declared with `over`),
* - `{ x?, y?, width | height }` or `{ fill }` for a freely placed one.
* Applied by `screenci sync` only (nothing to do at record time); stale
* Applied by the editor only (nothing to do at record time); stale
* variant keys (e.g. `fill` when switching to a box) are removed by the
* codemod. The id is `overlaydecl-<overlayName>` so upserts are
* last-write-wins per overlay.
@@ -357,7 +357,7 @@ export type EditorMediaEdit = {
/**
* Repositions an EXISTING, code-authored media call (a narration cue / overlay
* / audio item, identified by its declaration `name`) so it plays next to a
* different interaction. `screenci sync` locates the current call by its callee
* different interaction. The editor locates the current call by its callee
* head (`<root>.<name>`) in the gap next to `fromEditId`, removes it there, and
* re-places it next to `toEditId` with a preceding `waitForTimeout` gap
* (`sleepBeforeMs`) or, non-blocking, a `{ delay }` option (`delayMs`). When
@@ -392,7 +392,7 @@ export type RepositionMediaEdit = {
disabled?: boolean
}
/** Codify-only records: placed into code by `screenci sync`, never at runtime. */
/** Codify-only records: authored in the editor, never at runtime. */
export type CodifyEdit =
| MediaEdit
| ZoomEdit
+2 -2
View File
@@ -642,7 +642,7 @@ export type NarrationFullScreenFit = 'contain' | 'cover'
export type AutoZoomOptions = {
/**
* Stable identity slug for the web editor (e.g. `autoZoom2`). Stamped
* automatically by `screenci sync`; not a zoom setting and never marks the
* automatically at preview startup; not a zoom setting and never marks the
* block as code-locked.
*/
editId?: string
@@ -768,7 +768,7 @@ type CursorActionMoveOptions = {
move?: CursorMoveOptions
/**
* Stable identity slug for the web editor (e.g. `click1`). Stamped
* automatically by `screenci sync`; identity only, never affects the
* automatically at preview startup; identity only, never affects the
* action's behavior.
*/
editId?: string