Add screenci dev: connect this machine to the editor for on-demand records

New long-running command that registers a dev listener with the service,
short-polls for record triggers from the web editor, runs the requested
video and language through the normal record and upload path, and reports
status back. Authenticates with SCREENCI_SECRET plus a personal
SCREENCI_DEV_TOKEN so only the token owner's web session can trigger
records on this machine.

The previously removed dev command name is reintroduced on purpose; the
regression test now asserts the command exists instead.
This commit is contained in:
Olli Paloviita
2026-07-08 14:27:32 +03:00
parent 100e715a3a
commit 9c4cfd284f
7 changed files with 669 additions and 5 deletions
+1 -1
View File
@@ -376,7 +376,7 @@ describe('CLI', () => {
await expect(main()).rejects.toThrow('process.exit called')
expect(loggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('record, test')
expect.stringContaining('record, dev, test')
)
// The browser sign-in `login` command is gone from the OTP-based flow, so
// it must not be advertised as a top-level command.
+221
View File
@@ -0,0 +1,221 @@
import { describe, expect, it, vi } from 'vitest'
import type {
DevListenConfig,
DevListenDeps,
DevTrigger,
} from './src/devListen'
import {
DEV_TOKEN_HEADER,
DevAuthError,
deregisterDevListener,
pollDevListener,
registerDevListener,
reportDevTrigger,
runDevListenLoop,
} from './src/devListen'
const config: DevListenConfig = {
apiUrl: 'http://localhost:8787',
secret: 'org-secret',
devToken: 'dev-token',
projectName: 'demo',
machineName: 'laptop',
pollIntervalMs: 1,
errorBackoffMs: 1,
runHeartbeatMs: 1000,
}
const trigger: DevTrigger = {
triggerId: 'trg_1',
videoName: 'Intro video',
language: 'fi',
requestedByName: 'Olli',
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
function makeDeps(overrides: Partial<DevListenDeps> = {}): DevListenDeps & {
fetchMock: ReturnType<typeof vi.fn>
} {
const fetchMock = vi.fn(async () => jsonResponse({ ok: true }))
return {
fetchFn: fetchMock as unknown as typeof fetch,
fetchMock,
sleep: vi.fn(async () => {}),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
runRecord: vi.fn(async () => {}),
setIntervalFn: vi.fn(() => () => {}),
...overrides,
}
}
describe('registerDevListener', () => {
it('sends both credentials and the machine name', async () => {
const deps = makeDeps()
deps.fetchMock.mockResolvedValueOnce(
jsonResponse({ listenerId: 'lst_1', userName: 'Olli' })
)
const result = await registerDevListener(config, deps)
expect(result).toEqual({ listenerId: 'lst_1', userName: 'Olli' })
const [url, init] = deps.fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('http://localhost:8787/cli/dev/register')
const headers = init.headers as Record<string, string>
expect(headers['X-ScreenCI-Secret']).toBe('org-secret')
expect(headers[DEV_TOKEN_HEADER]).toBe('dev-token')
expect(JSON.parse(init.body as string)).toEqual({
projectName: 'demo',
machineName: 'laptop',
})
})
it('throws DevAuthError on a 401', async () => {
const deps = makeDeps()
deps.fetchMock.mockResolvedValueOnce(
jsonResponse({ error: 'Invalid dev token' }, 401)
)
await expect(registerDevListener(config, deps)).rejects.toBeInstanceOf(
DevAuthError
)
})
})
describe('pollDevListener', () => {
it('returns the claimed trigger', async () => {
const deps = makeDeps()
deps.fetchMock.mockResolvedValueOnce(jsonResponse({ trigger }))
await expect(pollDevListener(config, deps, 'lst_1')).resolves.toEqual(
trigger
)
})
it('returns null when nothing is pending', async () => {
const deps = makeDeps()
deps.fetchMock.mockResolvedValueOnce(jsonResponse({ trigger: null }))
await expect(pollDevListener(config, deps, 'lst_1')).resolves.toBeNull()
})
})
describe('runDevListenLoop', () => {
it('claims a trigger, reports running, records, and reports done', async () => {
const controller = { stopped: false }
const runRecord = vi.fn(async () => {})
const deps = makeDeps({ runRecord })
deps.fetchMock.mockImplementation(
async (url: string, init: RequestInit) => {
if (url.endsWith('/cli/dev/poll')) {
// First poll returns the trigger, later polls stop the loop.
if (deps.fetchMock.mock.calls.length === 1) {
return jsonResponse({ trigger })
}
controller.stopped = true
return jsonResponse({ trigger: null })
}
expect(url.endsWith('/cli/dev/report')).toBe(true)
void init
return jsonResponse({ ok: true })
}
)
await runDevListenLoop(config, deps, 'lst_1', controller)
expect(runRecord).toHaveBeenCalledWith(trigger)
const reports = deps.fetchMock.mock.calls
.filter(([url]) => (url as string).endsWith('/cli/dev/report'))
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
expect(reports.map((r) => r.state)).toEqual(['running', 'done'])
expect(reports[1].triggerId).toBe('trg_1')
})
it('reports failed with the error message when the record throws', async () => {
const controller = { stopped: false }
const runRecord = vi.fn(async () => {
throw new Error('Playwright exited with code 1')
})
const deps = makeDeps({ runRecord })
deps.fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/cli/dev/poll')) {
if (deps.fetchMock.mock.calls.length === 1) {
return jsonResponse({ trigger })
}
controller.stopped = true
return jsonResponse({ trigger: null })
}
return jsonResponse({ ok: true })
})
await runDevListenLoop(config, deps, 'lst_1', controller)
const reports = deps.fetchMock.mock.calls
.filter(([url]) => (url as string).endsWith('/cli/dev/report'))
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
expect(reports.map((r) => r.state)).toEqual(['running', 'failed'])
expect(reports[1].errorMessage).toBe('Playwright exited with code 1')
})
it('keeps polling with a backoff after a network error', async () => {
const controller = { stopped: false }
const sleep = vi.fn(async () => {})
const deps = makeDeps({ sleep })
deps.fetchMock.mockImplementation(async () => {
if (deps.fetchMock.mock.calls.length === 1) {
throw new Error('ECONNREFUSED')
}
controller.stopped = true
return jsonResponse({ trigger: null })
})
await runDevListenLoop(config, deps, 'lst_1', controller)
expect(deps.logger.warn).toHaveBeenCalledWith(
expect.stringContaining('ECONNREFUSED')
)
expect(sleep).toHaveBeenCalledWith(config.errorBackoffMs)
expect(deps.fetchMock.mock.calls.length).toBe(2)
})
it('stops and propagates DevAuthError when the token is revoked', async () => {
const controller = { stopped: false }
const deps = makeDeps()
deps.fetchMock.mockResolvedValue(
jsonResponse({ error: 'Invalid dev token' }, 401)
)
await expect(
runDevListenLoop(config, deps, 'lst_1', controller)
).rejects.toBeInstanceOf(DevAuthError)
})
})
describe('reportDevTrigger and deregisterDevListener', () => {
it('omits errorMessage unless provided', async () => {
const deps = makeDeps()
await reportDevTrigger(config, deps, 'lst_1', 'trg_1', 'done')
const body = JSON.parse(
(deps.fetchMock.mock.calls[0] as [string, RequestInit])[1].body as string
)
expect(body).toEqual({
projectName: 'demo',
listenerId: 'lst_1',
triggerId: 'trg_1',
state: 'done',
})
})
it('deregisters with the listener id', async () => {
const deps = makeDeps()
await deregisterDevListener(config, deps, 'lst_1')
const [url, init] = deps.fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('http://localhost:8787/cli/dev/deregister')
expect(JSON.parse(init.body as string).listenerId).toBe('lst_1')
})
})
+8 -2
View File
@@ -485,14 +485,20 @@ describe('CLI', () => {
expect(processExitSpy).toHaveBeenCalledWith(1)
})
it('should reject the removed dev command', async () => {
// `dev` was once a removed command; it is now the editor listener command,
// so it must parse as a real command (here it exits asking for the secret)
// instead of being rejected as unknown.
it('recognizes the dev command', async () => {
process.argv = ['node', 'cli.js', 'dev']
const { main } = await import('./cli')
await expect(main()).rejects.toThrow('process.exit called')
expect(loggerErrorSpy).toHaveBeenCalledWith('Unknown command: dev')
expect(loggerErrorSpy).not.toHaveBeenCalledWith('Unknown command: dev')
expect(loggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('SCREENCI_SECRET')
)
expect(processExitSpy).toHaveBeenCalledWith(1)
})
+177 -1
View File
@@ -28,6 +28,7 @@ import {
resolve,
} from 'path'
import { fileURLToPath, pathToFileURL } from 'url'
import { hostname } from 'os'
import { Command, CommanderError } from 'commander'
import { confirm } from '@inquirer/prompts'
import pc from 'picocolors'
@@ -113,6 +114,15 @@ import {
saveAnonSessionRecordUrl,
secretCredential,
} from './src/anonSession.js'
import {
type DevListenConfig,
type DevListenDeps,
DevAuthError,
SCREENCI_DEV_TOKEN_ENV,
deregisterDevListener,
registerDevListener,
runDevListenLoop,
} from './src/devListen.js'
// Re-export the environment-aware URL helpers so existing importers (and tests)
// can keep importing them from the CLI entrypoint.
@@ -3001,6 +3011,150 @@ async function triggerRemoteRun(
)
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
// Runs the record for one web-editor dev trigger: exactly one video (grep by
// its title) in exactly one language, then uploads. Mirrors the `record`
// command's local path, minus its remote/UPLOAD_EXISTING/hint concerns; the
// error message is reported back to the editor instead of the console.
async function runTriggeredRecord(
configPath: string | undefined,
trigger: { videoName: string; language: string },
verbose: boolean
): Promise<void> {
const resolvedConfigPath = resolveScreenCIConfigPathOrExit(configPath)
const screenciConfig =
await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath)
const screenciDir = resolve(dirname(resolvedConfigPath), '.screenci')
const grepArgs = ['--grep', escapeRegExp(trigger.videoName)]
const requestedVideoNames = await collectRequestedRecordVideoNames(
resolvedConfigPath,
grepArgs,
trigger.language
)
const recordRunLock = await acquireRecordRunLock(
screenciDir,
screenciConfig.projectName
)
try {
let playwrightFailure: Error | null = null
try {
await run(
'record',
grepArgs,
configPath,
verbose,
false,
trigger.language
)
} catch (error) {
if (!(error instanceof Error)) throw error
playwrightFailure = error
}
await uploadRecordedVideosForConfig(
configPath,
playwrightFailure,
verbose,
requestedVideoNames
)
if (playwrightFailure !== null) {
throw playwrightFailure
}
} finally {
await recordRunLock.release()
}
}
export async function runDevCommand(
options: { config?: string; verbose?: boolean; token?: string },
depsOverride: Partial<DevListenDeps> & { machineName?: string } = {}
): Promise<void> {
const { screenciConfig, secret, apiUrl } = await requireScreenCISecret(
options.config
)
const devToken = options.token ?? process.env[SCREENCI_DEV_TOKEN_ENV]
if (!devToken) {
logger.error(
`No ${SCREENCI_DEV_TOKEN_ENV} configured. Create a personal dev token at ${pc.cyan(getScreenCISecretsUrl())} and add it to your env file, or pass it with --token.`
)
process.exit(1)
}
const config: DevListenConfig = {
apiUrl,
secret,
devToken,
projectName: screenciConfig.projectName,
machineName: depsOverride.machineName ?? hostname(),
}
const deps: DevListenDeps = {
fetchFn: fetch,
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
logger,
runRecord: (trigger) =>
runTriggeredRecord(options.config, trigger, options.verbose ?? false),
...depsOverride,
}
let registration: { listenerId: string; userName: string }
try {
registration = await registerDevListener(config, deps)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.error(`Failed to connect: ${message}`)
if (error instanceof DevAuthError) {
logger.error(
`Check your dev token at ${pc.cyan(getScreenCISecretsUrl())}.`
)
}
process.exit(1)
}
logger.info(
`Connected as ${pc.bold(`${registration.userName}@${config.machineName}`)} for project "${screenciConfig.projectName}".`
)
logger.info(
`Waiting for record requests from ${pc.cyan(getDevFrontendUrl())}. Press Ctrl-C to stop.`
)
const controller = { stopped: false }
const shutdown = () => {
if (controller.stopped) return
controller.stopped = true
logger.info('Disconnecting...')
void deregisterDevListener(config, deps, registration.listenerId)
.catch(() => {})
.finally(() => process.exit(0))
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
try {
await runDevListenLoop(config, deps, registration.listenerId, controller)
} catch (error) {
if (error instanceof DevAuthError) {
logger.error(error.message)
logger.error(
`Create a new dev token at ${pc.cyan(getScreenCISecretsUrl())} if yours was revoked.`
)
await deregisterDevListener(config, deps, registration.listenerId).catch(
() => {}
)
process.exit(1)
}
throw error
} finally {
process.off('SIGINT', shutdown)
process.off('SIGTERM', shutdown)
}
}
function getRecordRunLockPath(screenciDir: string): string {
return resolve(screenciDir, SCREENCI_RECORD_LOCK_FILE)
}
@@ -3711,7 +3865,7 @@ export async function main() {
if (process.argv.length <= 2) {
logger.error('Error: No command provided')
logger.error(
'Available commands: record, test, info, make-public, make-private, delete, init'
'Available commands: record, dev, test, info, make-public, make-private, delete, init'
)
process.exit(1)
}
@@ -3828,6 +3982,28 @@ export async function main() {
}
})
// dev command: connect this machine to the web editor and record on demand
program
.command('dev')
.description(
'Connect this machine to the ScreenCI editor and record videos on demand'
)
.option('-c, --config <path>', 'path to config file')
.option('-v, --verbose', 'verbose output')
.option(
'--token <token>',
`personal dev token (defaults to ${SCREENCI_DEV_TOKEN_ENV} from your env file)`
)
.action(
async (options: {
config?: string
verbose?: boolean
token?: string
}) => {
await runDevCommand(options)
}
)
program
.command('test [playwrightArgs...]')
.description('Run Playwright test with screenci.config.ts')
+45
View File
@@ -15,6 +15,7 @@ commands from inside your `screenci/` project directory. Most commands resolve
| `screenci init [name]` | Scaffold a ScreenCI project |
| `screenci test [playwrightArgs]` | Run `.screenci.ts` files locally without final recording |
| `screenci record [playwrightArgs]` | Record videos and upload results when configured |
| `screenci dev` | Connect this machine to the editor for on-demand records |
| `screenci info` | Print the last record run's URLs and render status |
| `screenci status` | Compare Editor action edits with the latest recorded run |
| `screenci sync-prompt` | Print an agent prompt to sync code with Editor edits |
@@ -340,6 +341,50 @@ Without a connected repository the command reports that GitHub is not connected.
Targeted (`--grep`) runs need the workflow to declare a `grep` input; projects
scaffolded by `screenci init` include it.
## `screenci dev`
Connects this machine to the ScreenCI editor so a Record button in the editor
can trigger a local `screenci record` of one video and language on demand. The
command keeps running, polling the service for record requests, until you stop
it with Ctrl-C.
```bash
screenci dev
```
Options:
- `-c, --config <path>`: path to the ScreenCI config file.
- `--token <token>`: personal dev token. Defaults to `SCREENCI_DEV_TOKEN` from
your project env file.
- `-v, --verbose`: verbose output.
Setup:
1. Create a personal dev token on the Secrets page in the app (one token per
machine, up to five per user).
2. Add it to your project env file as `SCREENCI_DEV_TOKEN=<token>`, next to
your `SCREENCI_SECRET`.
3. Run `screenci dev` in your project. The editor header shows your machine as
connected (for example `you@laptop`) and offers "Record ... on laptop" in
the Render button's record menu.
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 and renders as a normal record run.
- The regular record run lock applies: if another `screenci record` is already
running, 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.
Dev tokens are separate from `SCREENCI_SECRET`: the secret authenticates your
project to the service, the dev token additionally proves which user and
machine is listening. Revoking the token on the Secrets page immediately
disconnects the machine.
## `screenci info`
<Tabs syncKey="package-manager">
+1 -1
View File
@@ -302,7 +302,7 @@ export const docsManifest = [
navLabel: 'CLI',
title: 'CLI',
description:
'Command reference for screenci init, test, record, project info, and public delivery commands.',
'Command reference for screenci init, test, record, dev, project info, and public delivery commands.',
prev: 'docs/guides/anonymous-trial',
next: 'docs/reference/configuration',
},
+216
View File
@@ -0,0 +1,216 @@
/**
* Poll loop behind `screenci dev`: registers this machine as a dev listener,
* heartbeats via short polling, and runs a record when the web editor asks
* for one. All side effects (fetch, sleeping, the actual record run) are
* injected so the loop is unit-testable.
*/
export const DEV_TOKEN_HEADER = 'X-ScreenCI-Dev-Token'
export const SCREENCI_DEV_TOKEN_ENV = 'SCREENCI_DEV_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
export type DevTrigger = {
triggerId: string
videoName: string
language: string
requestedByName: string
}
export type DevListenLogger = {
info: (message: string) => void
warn: (message: string) => void
error: (message: string) => void
}
export type DevListenDeps = {
fetchFn: typeof fetch
sleep: (ms: number) => Promise<void>
logger: DevListenLogger
/** Runs the actual record for a claimed trigger; throws on failure. */
runRecord: (trigger: DevTrigger) => Promise<void>
/** Registers a heartbeat timer during a run; returns a cancel function. */
setIntervalFn?: (fn: () => void, ms: number) => () => void
}
export type DevListenConfig = {
apiUrl: string
secret: string
devToken: string
projectName: string
machineName: string
pollIntervalMs?: number
errorBackoffMs?: number
runHeartbeatMs?: number
}
/** Thrown when the backend rejects our credentials; the loop 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: DevListenDeps,
path: string,
body: Record<string, unknown>
): Promise<T> {
const res = await deps.fetchFn(`${config.apiUrl}${path}`, {
method: 'POST',
headers: {
'X-ScreenCI-Secret': config.secret,
[DEV_TOKEN_HEADER]: config.devToken,
'Content-Type': 'application/json',
},
body: JSON.stringify({ projectName: config.projectName, ...body }),
})
if (res.status === 401) {
const text = await res.text().catch(() => '')
throw new DevAuthError(
`The backend rejected this session (401). Your dev token may have been revoked. ${text}`.trim()
)
}
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Request to ${path} failed: ${res.status} ${text}`.trim())
}
return (await res.json()) as T
}
export async function registerDevListener(
config: DevListenConfig,
deps: DevListenDeps
): Promise<{ listenerId: string; userName: string }> {
return await postDev(config, deps, '/cli/dev/register', {
machineName: config.machineName,
})
}
export async function pollDevListener(
config: DevListenConfig,
deps: DevListenDeps,
listenerId: string
): Promise<DevTrigger | null> {
const result = await postDev<{ trigger: DevTrigger | null }>(
config,
deps,
'/cli/dev/poll',
{ listenerId }
)
return result.trigger
}
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 deregisterDevListener(
config: DevListenConfig,
deps: DevListenDeps,
listenerId: string
): Promise<void> {
await postDev(config, deps, '/cli/dev/deregister', { listenerId })
}
async function handleTrigger(
config: DevListenConfig,
deps: DevListenDeps,
listenerId: string,
trigger: DevTrigger
): Promise<void> {
const setIntervalFn = deps.setIntervalFn ?? defaultSetInterval
deps.logger.info(
`Record requested by ${trigger.requestedByName}: "${trigger.videoName}" (${trigger.language})`
)
await reportDevTrigger(config, deps, listenerId, trigger.triggerId, 'running')
// Re-report `running` periodically so a long record keeps the listener's
// heartbeat fresh; best-effort, a missed beat only delays the status UI.
const cancelHeartbeat = setIntervalFn(() => {
void reportDevTrigger(
config,
deps,
listenerId,
trigger.triggerId,
'running'
).catch(() => {})
}, config.runHeartbeatMs ?? DEV_RUN_HEARTBEAT_MS)
try {
await deps.runRecord(trigger)
cancelHeartbeat()
await reportDevTrigger(config, deps, listenerId, trigger.triggerId, 'done')
deps.logger.info(
`Finished recording "${trigger.videoName}" (${trigger.language}).`
)
} catch (error) {
cancelHeartbeat()
const message = error instanceof Error ? error.message : String(error)
deps.logger.error(
`Record for "${trigger.videoName}" (${trigger.language}) failed: ${message}`
)
await reportDevTrigger(
config,
deps,
listenerId,
trigger.triggerId,
'failed',
message
)
}
}
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
while (!controller.stopped) {
let delayMs = pollIntervalMs
try {
const trigger = await pollDevListener(config, deps, listenerId)
if (trigger && !controller.stopped) {
await handleTrigger(config, deps, listenerId, trigger)
}
} catch (error) {
if (error instanceof DevAuthError) throw error
const message = error instanceof Error ? error.message : String(error)
deps.logger.warn(`Connection problem, retrying: ${message}`)
delayMs = errorBackoffMs
}
if (controller.stopped) return
await deps.sleep(delayMs)
}
}