mirror of
https://github.com/screenci/screenci.git
synced 2026-09-19 08:57:46 +08:00
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:
+1
-1
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user