mirror of
https://github.com/screenci/screenci.git
synced 2026-09-19 08:57:46 +08:00
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:
+18
-992
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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"'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
|
||||
/**
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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' }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
-2384
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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,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'
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user