mirror of
https://github.com/screenci/screenci.git
synced 2026-09-19 08:57:46 +08:00
04393fa30e
Editor edits now arrive over the dev channel as codegen requests and are written straight into the .screenci.ts sources; recordings always run purely from code values. - Keep data.json across uploads (media-only cleanup, last-data.json rename) and stamp a sourceHash of the test file into its metadata. - screenci dev startup handshake: skip recording when the source hash matches and every editable action has an editId; otherwise stamp missing editIds and re-record as a preview. New --grep, --force-record and --record-kill-window flags. - Dev loop: records run in a killable background slot while the loop keeps polling; codegen requests apply serially via the codemod pipeline and are acked over /cli/dev/report-codegen; fast-poll during active editing; kill-or-queue policy for superseding record triggers; sync-state reporting locks editor timelines during the startup handshake. - Remove the server-override machinery: editable-actions.json and action-params.json snapshots, SCREENCI_TIMELINE_EDITS injection, runtime override application, screenci sync / status / reset-web-edits and dev --sync.
1405 lines
46 KiB
TypeScript
1405 lines
46 KiB
TypeScript
import { test as base } from '@playwright/test'
|
|
import type {
|
|
TestType,
|
|
PlaywrightTestArgs,
|
|
PlaywrightTestOptions,
|
|
PlaywrightWorkerArgs,
|
|
PlaywrightWorkerOptions,
|
|
TestDetails,
|
|
TestInfo,
|
|
} from '@playwright/test'
|
|
import { mkdir, rm } from 'fs/promises'
|
|
import { join, relative } from 'path'
|
|
import { attachRecorder } from 'playwright-recorder-plus'
|
|
import type { Recorder, StopResult } from 'playwright-recorder-plus'
|
|
import type { Page as RecorderPage } from 'playwright-core'
|
|
import type {
|
|
AspectRatio,
|
|
FPS,
|
|
Quality,
|
|
RecordOptions,
|
|
RenderOptions,
|
|
ScreenCIPage,
|
|
VideoEncoderPreset,
|
|
} from './types.js'
|
|
import type { Page } from '@playwright/test'
|
|
import { ScreenciError } from './errors.js'
|
|
import {
|
|
installAnimationDisabling,
|
|
resolveDisableAnimations,
|
|
} from './disableAnimations.js'
|
|
export { getDimensions } from './dimensions.js'
|
|
import { getDimensions, getViewportCenter } from './dimensions.js'
|
|
import { resetCueChain } from './cue.js'
|
|
import { setActiveCueRecorder } from './cue.js'
|
|
import {
|
|
createVideoBuilder,
|
|
type MediaBuilder,
|
|
type ResolvedRecordingLocalize,
|
|
} from './builder.js'
|
|
import type { NormalizedFeature } from './declare.js'
|
|
import type { LocalizeNarrationValue } from './localize.js'
|
|
import {
|
|
buildNarrationMarkers,
|
|
buildValuesDeclaration,
|
|
buildValues,
|
|
narrationVoiceConfigFromRenderOptions,
|
|
type NarrationMarkers,
|
|
type ValuesDeclaration,
|
|
type Values,
|
|
} from './localizeRuntime.js'
|
|
import { setActiveHideRecorder } from './hide.js'
|
|
import { setActiveAutoZoomRecorder, setActiveZoomPage } from './autoZoom.js'
|
|
import {
|
|
setActiveAssetRecorder,
|
|
buildOverlays,
|
|
validateRegisteredAssetPaths,
|
|
type OverlayController,
|
|
type OverlayInputOrFactory,
|
|
} from './asset.js'
|
|
import { flushPendingOverlays } from './overlayFlush.js'
|
|
import {
|
|
setActiveAudioRecorder,
|
|
buildAudio,
|
|
validateRegisteredAudioPaths,
|
|
type AudioController,
|
|
type AudioInput,
|
|
} from './audio.js'
|
|
import {
|
|
DEFAULT_VIDEO_OPTIONS,
|
|
DEFAULT_ASPECT_RATIO,
|
|
DEFAULT_QUALITY,
|
|
DEFAULT_FPS,
|
|
DEFAULT_VIDEO_ENCODER,
|
|
} from './defaults.js'
|
|
import { EventRecorder } from './events.js'
|
|
import {
|
|
bindClickRecorderToPage,
|
|
instrumentBrowser,
|
|
instrumentContext,
|
|
setActiveClickRecorder,
|
|
} from './instrument.js'
|
|
import { logger } from './logger.js'
|
|
import { setMousePosition } from './mouse.js'
|
|
import {
|
|
captureRequestedButNotEnabled,
|
|
getChromiumLaunchOptions,
|
|
isCaptureAudioEnabled,
|
|
resolveCaptureAudioGain,
|
|
} from './browserLaunchOptions.js'
|
|
import {
|
|
createScreenCIRuntimeContext,
|
|
runWithScreenCIRuntimeContext,
|
|
setActiveScreenCIRuntimeContext,
|
|
setRuntimeAssetRecorder,
|
|
setRuntimeAudioRecorder,
|
|
setRuntimeAutoZoomRecorder,
|
|
setRuntimeClickRecorder,
|
|
setRuntimeCueRecorder,
|
|
setRuntimeHideRecorder,
|
|
setRuntimePage,
|
|
} from './runtimeContext.js'
|
|
import { installRedactController } from './redact.js'
|
|
import { escapeFileSystemPathSegment } from './fileSystemName.js'
|
|
import {
|
|
resolveRecordingTimingDuration,
|
|
parseValuesOverrides,
|
|
parseRecordOptions,
|
|
mergeStudioRecordOptions,
|
|
} from './runtimeMode.js'
|
|
import { ActionParamCollector } from './actionParams.js'
|
|
import {
|
|
combineRecordOptionsLayers,
|
|
combineRenderOptionsLayers,
|
|
} from './optionsDeclare.js'
|
|
import { buildScreenCIContextOptions } from './contextOptions.js'
|
|
import { bindStillCaptureToPage } from './stillCapture.js'
|
|
import {
|
|
startScreenAudioCapture,
|
|
isScreenAudioSupported,
|
|
screenAudioUnsupportedMessage,
|
|
setActiveCaptureDevice,
|
|
SCREEN_AUDIO_DOCS_URL,
|
|
} from './screenAudio.js'
|
|
import type { ScreenAudioCapture } from './screenAudio.js'
|
|
import {
|
|
assertScreenAudioCaptureReady,
|
|
createNullSink,
|
|
unloadNullSink,
|
|
workerSinkName,
|
|
} from './screenAudioSink.js'
|
|
import type { NullSink } from './screenAudioSink.js'
|
|
|
|
export const POST_VIDEO_PAUSE = 500
|
|
|
|
/** The old `'studio'` string sentinel is retired: pass a plain options object. */
|
|
function assertNotLegacyStudioString(value: unknown, option: string): void {
|
|
if (value === 'studio') {
|
|
throw new ScreenciError(
|
|
`use({ ${option}: 'studio' }) is no longer supported. Every recording is ` +
|
|
`web-editable now: pass a plain ${option} object (or omit it) and edit ` +
|
|
`the values in the web app.`
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The record options actually used for the capture: web-editor record options
|
|
* fetched before recording (keyed by video name) override the code aspect
|
|
* ratio, quality, and fps.
|
|
*/
|
|
export function resolveEffectiveRecordOptions(
|
|
recordOptions: RecordOptions,
|
|
videoName: string
|
|
): RecordOptions {
|
|
return mergeStudioRecordOptions(
|
|
recordOptions,
|
|
parseRecordOptions()?.[videoName]
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Resolve the `recordOptions` option: the code values merged over the defaults
|
|
* (config layer plus per-video declaration, pre-combined by the caller). Every
|
|
* recording's capture options are editable in the web app; the code values are
|
|
* the starting point.
|
|
*/
|
|
export function resolveStudioRecordOptions(
|
|
recordOptions: RecordOptions | Partial<RecordOptions> | undefined
|
|
): { base: RecordOptions } {
|
|
assertNotLegacyStudioString(recordOptions, 'recordOptions')
|
|
return {
|
|
base: recordOptions
|
|
? { ...DEFAULT_VIDEO_OPTIONS, ...recordOptions }
|
|
: DEFAULT_VIDEO_OPTIONS,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the `renderOptions` option. Every recording's render options are
|
|
* editable in the web app; the code values (or the defaults, resolved at write
|
|
* time) are the starting point.
|
|
*/
|
|
export function resolveStudioRenderOptions(
|
|
renderOptions: RenderOptions | undefined
|
|
): {
|
|
obj: RenderOptions | undefined
|
|
} {
|
|
assertNotLegacyStudioString(renderOptions, 'renderOptions')
|
|
return { obj: renderOptions }
|
|
}
|
|
|
|
type DeferredRecordingStop = {
|
|
recorder: Recorder
|
|
}
|
|
|
|
type WorkerFinalizationQueue = DeferredRecordingStop[]
|
|
|
|
export async function finalizeDeferredRecordingStops(
|
|
entries: DeferredRecordingStop[]
|
|
): Promise<void> {
|
|
await Promise.all(
|
|
entries.map(async ({ recorder }) => {
|
|
const stopResult = await recorder.stop()
|
|
if (!stopResult.written) {
|
|
logger.warn(
|
|
'Screen recording did not write any frames. Test will continue without recording.'
|
|
)
|
|
}
|
|
await recorder.finalized
|
|
})
|
|
)
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) =>
|
|
setTimeout(resolve, resolveRecordingTimingDuration(ms))
|
|
)
|
|
}
|
|
|
|
async function setupMouseTracking(
|
|
_page: Page,
|
|
_recorder: EventRecorder
|
|
): Promise<void> {
|
|
/*
|
|
await page.exposeFunction(
|
|
'__screenci_recordMouseMove',
|
|
(x: number, y: number) => {
|
|
recorder.addMouseMove(x, y)
|
|
}
|
|
)
|
|
|
|
await page.addInitScript(() => {
|
|
// Throttle mouse moves to ~60fps (16ms) to keep data.json size manageable
|
|
let lastMouseMove = 0
|
|
document.addEventListener(
|
|
'mousemove',
|
|
(e) => {
|
|
const now = Date.now()
|
|
if (now - lastMouseMove >= 16) {
|
|
lastMouseMove = now
|
|
;(
|
|
window as Window & {
|
|
__screenci_recordMouseMove?: (x: number, y: number) => void
|
|
}
|
|
).__screenci_recordMouseMove?.(e.clientX, e.clientY)
|
|
}
|
|
},
|
|
{ capture: true }
|
|
)
|
|
})
|
|
*/
|
|
}
|
|
|
|
export async function positionMouseAtViewportCenter(
|
|
page: Page,
|
|
dimensions: { width: number; height: number }
|
|
): Promise<{ x: number; y: number }> {
|
|
const viewportCenter = getViewportCenter(dimensions)
|
|
await (
|
|
page.mouse as typeof page.mouse & {
|
|
_move: (x: number, y: number) => Promise<void>
|
|
}
|
|
)._move(viewportCenter.x, viewportCenter.y)
|
|
setMousePosition(page, viewportCenter)
|
|
|
|
return viewportCenter
|
|
}
|
|
|
|
/**
|
|
* Encoder arguments for the screencast's realtime first pass, per preset.
|
|
*
|
|
* We keep the recorder's realtime first pass as the final artifact (the second
|
|
* pass is disabled below via `runSecondPass`), so these are the only encoder
|
|
* settings that reach the saved recording. playwright-recorder-plus has no
|
|
* public option for the first-pass encoder, so `applyFirstPassEncoderArgs`
|
|
* overrides the recorder's internal first-pass args at runtime. Doing it at
|
|
* runtime (rather than patching the dependency on disk) means it works for
|
|
* every install: npm, pnpm and yarn, with no postinstall step.
|
|
*
|
|
* - `'sharp'` is tuned for text-heavy UI: `-tune stillimage` and a low `-crf`
|
|
* preserve sharp glyph edges, while `-preset veryfast` stays above realtime
|
|
* so the screencast stream never backpressures (which drops frames and
|
|
* shortens the timeline).
|
|
* - `'fast'` mirrors the library's original `-preset ultrafast -crf 18` for
|
|
* resource-constrained CI that cannot keep up with the sharper encode.
|
|
*
|
|
* Both use `yuv420p` so the output stays decodable by the downstream NVDEC
|
|
* CUDA render pipeline (4:4:4 H.264 is not reliably hardware-decodable).
|
|
*/
|
|
const FIRST_PASS_ARGS_BY_ENCODER: Record<
|
|
VideoEncoderPreset,
|
|
readonly string[]
|
|
> = {
|
|
sharp: [
|
|
'-c:v',
|
|
'libx264',
|
|
'-preset',
|
|
'veryfast',
|
|
'-crf',
|
|
'12',
|
|
'-tune',
|
|
'stillimage',
|
|
'-pix_fmt',
|
|
'yuv420p',
|
|
'-movflags',
|
|
'+faststart',
|
|
],
|
|
fast: [
|
|
'-c:v',
|
|
'libx264',
|
|
'-preset',
|
|
'ultrafast',
|
|
'-crf',
|
|
'18',
|
|
'-pix_fmt',
|
|
'yuv420p',
|
|
'-movflags',
|
|
'+faststart',
|
|
],
|
|
}
|
|
|
|
/**
|
|
* Resolve the first-pass encoder args for a given preset. Exported for testing.
|
|
*/
|
|
export function resolveRecordingFirstPassArgs(
|
|
encoder: VideoEncoderPreset = DEFAULT_VIDEO_ENCODER
|
|
): readonly string[] {
|
|
return FIRST_PASS_ARGS_BY_ENCODER[encoder]
|
|
}
|
|
|
|
/**
|
|
* Replace the output-encoder tail of a built first-pass ffmpeg arg list.
|
|
*
|
|
* The recorder's first-pass args contain two `-c:v` flags: the first selects
|
|
* the mjpeg input decoder, the last selects the output encoder. We swap
|
|
* everything from that last `-c:v` to the end with `encoderArgs` (which start
|
|
* at their own `-c:v`), leaving the input/rate flags before it untouched.
|
|
*
|
|
* Pure and exported for testing. Throws if no output encoder is present, so a
|
|
* future change to the recorder's internals fails loudly instead of silently
|
|
* recording with the wrong encoder.
|
|
*/
|
|
export function overrideFirstPassEncoderArgs(
|
|
currentArgs: readonly string[],
|
|
encoderArgs: readonly string[]
|
|
): string[] {
|
|
const encoderStart = currentArgs.lastIndexOf('-c:v')
|
|
if (encoderStart === -1) {
|
|
throw new Error(
|
|
'playwright-recorder-plus first-pass args contained no output encoder (-c:v); ' +
|
|
'cannot apply the configured capture encoder. The recorder internals may have changed.'
|
|
)
|
|
}
|
|
return [...currentArgs.slice(0, encoderStart), ...encoderArgs]
|
|
}
|
|
|
|
/** Minimal view of the recorder's internal config that we override in place. */
|
|
type RecorderWithFirstPassConfig = {
|
|
config?: { firstPassArgs?: unknown }
|
|
}
|
|
|
|
/**
|
|
* Override the recorder's first-pass encoder in place, before `start()`.
|
|
*
|
|
* `attachRecorder` builds `config.firstPassArgs` eagerly but only spawns the
|
|
* first-pass ffmpeg process on `start()`, so mutating it here (with
|
|
* `autoStart: false`) takes effect for the actual recording.
|
|
*/
|
|
export function applyFirstPassEncoderArgs(
|
|
recorder: Recorder,
|
|
encoderArgs: readonly string[]
|
|
): void {
|
|
const config = (recorder as RecorderWithFirstPassConfig).config
|
|
const current = config?.firstPassArgs
|
|
if (!config || !Array.isArray(current)) {
|
|
throw new Error(
|
|
'playwright-recorder-plus recorder did not expose config.firstPassArgs; ' +
|
|
'cannot apply the configured capture encoder. The recorder internals may have changed.'
|
|
)
|
|
}
|
|
config.firstPassArgs = overrideFirstPassEncoderArgs(
|
|
current as string[],
|
|
encoderArgs
|
|
)
|
|
}
|
|
|
|
async function startScreencastRecording(
|
|
page: Page,
|
|
outputPath: string,
|
|
fps: FPS,
|
|
quality: Quality,
|
|
aspectRatio: AspectRatio,
|
|
encoder: VideoEncoderPreset
|
|
): Promise<Recorder> {
|
|
const { width, height } = getDimensions(aspectRatio, quality)
|
|
|
|
const recorder = await attachRecorder(page as unknown as RecorderPage, {
|
|
path: outputPath,
|
|
intermediatePath: outputPath,
|
|
autoStart: false,
|
|
size: { width, height },
|
|
fps,
|
|
jpegQuality: 100,
|
|
})
|
|
|
|
// Apply the configured capture encoder. The library has no public option for
|
|
// the first-pass encoder, so we override the recorder's internal first-pass
|
|
// args in place (before start(), which autoStart: false guarantees).
|
|
applyFirstPassEncoderArgs(recorder, resolveRecordingFirstPassArgs(encoder))
|
|
|
|
// Keep the recorder's realtime first-pass mp4 as the final artifact.
|
|
// This disables the library's background second-pass transcode so shared
|
|
// worker teardown only needs to flush stop() after all tests have paused.
|
|
;(
|
|
recorder as Recorder & {
|
|
runSecondPass?: (firstPass: StopResult) => Promise<StopResult>
|
|
}
|
|
).runSecondPass = async (firstPass) => firstPass
|
|
|
|
return recorder
|
|
}
|
|
|
|
/**
|
|
* Fails the recording if any overlay was `start()`ed but never `end()`ed.
|
|
* Every live overlay must be paired so the renderer never sees a dangling
|
|
* `assetStart` with no `assetEnd`. Only called on the success path, so it never
|
|
* masks an error thrown by the video function itself.
|
|
*/
|
|
export function assertAllOverlaysEnded(
|
|
runtimeContext: ReturnType<typeof createScreenCIRuntimeContext>
|
|
): void {
|
|
const open = [...runtimeContext.asset.activeRuns.keys()]
|
|
if (open.length === 0) return
|
|
const names = open.map((name) => `"${name}"`).join(', ')
|
|
throw new Error(
|
|
`[screenci] Overlay(s) ${names} were started with .start() but never ended. Call end() for each overlay before the video function returns.`
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Ends any overlays left open with `.start()`, in record order. Used by the
|
|
* screenshot fixture, where a `.start()` with no `.end()` means "keep this
|
|
* overlay visible in the still" rather than being an error (the still has no
|
|
* timeline, so the renderer shows every overlay regardless).
|
|
*/
|
|
export function autoEndOpenOverlays(
|
|
runtimeContext: ReturnType<typeof createScreenCIRuntimeContext>,
|
|
recorder: EventRecorder
|
|
): void {
|
|
for (const [name, run] of runtimeContext.asset.activeRuns) {
|
|
recorder.addAssetEnd(name, 'auto')
|
|
run.resolveFinished()
|
|
}
|
|
runtimeContext.asset.activeRuns.clear()
|
|
}
|
|
|
|
export async function withActiveRecordingContext<T>(params: {
|
|
runtimeContext: ReturnType<typeof createScreenCIRuntimeContext>
|
|
page: Page
|
|
recorder: EventRecorder
|
|
fn: () => Promise<T>
|
|
/**
|
|
* How to handle overlays left open at the end of the body. `'throw'` (video)
|
|
* rejects dangling overlays; `'autoEnd'` (screenshot) ends them so a badge
|
|
* `.start()`ed without `.end()` stays visible in the still.
|
|
*/
|
|
unendedOverlays?: 'throw' | 'autoEnd'
|
|
/**
|
|
* Localized `values` field declaration to emit once at recording start so the
|
|
* backend learns which fields exist and their seeds. `null`/omitted when the
|
|
* spec declares no `values`.
|
|
*/
|
|
valuesDeclaration?: ValuesDeclaration | null
|
|
}): Promise<T> {
|
|
const {
|
|
runtimeContext,
|
|
page,
|
|
recorder,
|
|
fn,
|
|
unendedOverlays = 'throw',
|
|
valuesDeclaration,
|
|
} = params
|
|
|
|
setActiveScreenCIRuntimeContext(runtimeContext)
|
|
|
|
try {
|
|
return await runWithScreenCIRuntimeContext(runtimeContext, async () => {
|
|
resetCueChain()
|
|
setRuntimeCueRecorder(recorder)
|
|
setRuntimeHideRecorder(recorder)
|
|
setRuntimeAutoZoomRecorder(recorder)
|
|
setRuntimeAssetRecorder(recorder)
|
|
setRuntimeAudioRecorder(recorder)
|
|
setRuntimeClickRecorder(recorder)
|
|
setActiveCueRecorder(recorder)
|
|
setActiveHideRecorder(recorder)
|
|
setActiveAutoZoomRecorder(recorder)
|
|
setActiveZoomPage(page)
|
|
setActiveAssetRecorder(recorder)
|
|
setActiveAudioRecorder(recorder)
|
|
setActiveClickRecorder(recorder)
|
|
bindClickRecorderToPage(page, recorder)
|
|
setRuntimePage(page)
|
|
|
|
// Fail fast on missing overlay/audio files before the body runs. Only
|
|
// when actually recording and when testFilePath is known (relative asset
|
|
// paths can only be resolved with an anchor file).
|
|
if (
|
|
runtimeContext.recordingDir !== null &&
|
|
runtimeContext.testFilePath !== null
|
|
) {
|
|
await validateRegisteredAssetPaths(runtimeContext.testFilePath)
|
|
await validateRegisteredAudioPaths(runtimeContext.testFilePath)
|
|
}
|
|
|
|
if (valuesDeclaration) {
|
|
recorder.addValuesDeclare(
|
|
valuesDeclaration.fields,
|
|
valuesDeclaration.studioFields,
|
|
valuesDeclaration.seed
|
|
)
|
|
}
|
|
|
|
const result = await fn()
|
|
if (unendedOverlays === 'autoEnd') {
|
|
autoEndOpenOverlays(runtimeContext, recorder)
|
|
} else {
|
|
assertAllOverlaysEnded(runtimeContext)
|
|
}
|
|
// Rasterize deferred overlays now that the test body has succeeded and
|
|
// every overlay's props/timing are known, before the recorder is written
|
|
// to disk. On the failure path fn() throws, so this never runs and no
|
|
// partial assets are produced (matching writeToFile, which only runs on a
|
|
// passing test).
|
|
await flushPendingOverlays(recorder)
|
|
return result
|
|
})
|
|
} finally {
|
|
setActiveScreenCIRuntimeContext(null)
|
|
}
|
|
}
|
|
|
|
type VideoFixtureOptions = {
|
|
/**
|
|
* Config-level record options (`use.recordOptions` in `screenci.config`),
|
|
* remapped by `defineConfig`. The project-wide default layer; per-video
|
|
* options come from `video.recordOptions(...)`. Internal.
|
|
*/
|
|
_screenciConfigRecordOptions: RecordOptions
|
|
/** Config-level render options (`use.renderOptions`). Internal. */
|
|
_screenciConfigRenderOptions: RenderOptions | undefined
|
|
/**
|
|
* Per-video record options declared via `video.recordOptions(...)`, pre-merged
|
|
* for this pass by the fan-out builder (base + language override + variant
|
|
* patch). Internal.
|
|
*/
|
|
_screenciRecordOptions: Partial<RecordOptions> | undefined
|
|
/** Per-video render options declared via `video.renderOptions(...)`. Internal. */
|
|
_screenciRenderOptions: Partial<RenderOptions> | undefined
|
|
/**
|
|
* Active language for this recording pass, set by `video.languages(...)` in
|
|
* per-language mode. `undefined` in shared mode and single-language videos.
|
|
* Internal: prefer the `language` fixture in test bodies.
|
|
*/
|
|
_screenciLanguage: string | undefined
|
|
/**
|
|
* Grouping name written to `metadata.videoName`, set by the fan-out builders
|
|
* so per-language passes (which use unique test titles) still group into one
|
|
* video. Falls back to the test title. Internal.
|
|
*/
|
|
_screenciVideoName: string | undefined
|
|
/** Narration declaration (`video.narration(...)`). Internal. */
|
|
_screenciNarration: NormalizedFeature<LocalizeNarrationValue> | undefined
|
|
/** Values-field declaration (`video.values(...)`). Internal. */
|
|
_screenciValues: NormalizedFeature<string> | undefined
|
|
/** Overlay declaration (`video.overlays(...)`). Internal. */
|
|
_screenciOverlays: NormalizedFeature<OverlayInputOrFactory> | undefined
|
|
/** Audio declaration (`video.audio(...)`). Internal. */
|
|
_screenciAudio: NormalizedFeature<AudioInput> | undefined
|
|
/** Resolved recording-level localize config (languages/mode). Internal. */
|
|
_screenciRecordingLocalize: ResolvedRecordingLocalize | undefined
|
|
/**
|
|
* Absolute path of the `.screenci` script that registered this test, captured
|
|
* by the fan-out builder. Asset paths are resolved relative to it, since
|
|
* `testInfo.file` points at the builder module, not the script. Internal.
|
|
*/
|
|
_screenciSourceFile: string | undefined
|
|
}
|
|
|
|
type VideoRuntimeFixtures = {
|
|
/**
|
|
* The language being recorded in this pass, for per-language navigation
|
|
* (e.g. `page.goto('/' + language)`). `undefined` in shared mode and
|
|
* non-localized videos.
|
|
*/
|
|
language: string | undefined
|
|
/**
|
|
* Narration markers keyed by the cue names declared in `video.narration(...)`.
|
|
* Each marker records timing (`()`, `.start()`, `.end()`); the text is owned by
|
|
* the narration spec / Studio and is never exposed here.
|
|
*/
|
|
narration: NarrationMarkers
|
|
/**
|
|
* Injected values fields for the active language, keyed by the field names
|
|
* declared in `video.values(...)`. Use them to fill localized content into
|
|
* the page.
|
|
*/
|
|
values: Values
|
|
/**
|
|
* Overlay controllers for the names declared in `video.overlays(...)`. Each is
|
|
* callable with `start()`/`end()`. Empty when none are declared.
|
|
*/
|
|
overlays: Record<
|
|
string,
|
|
OverlayController | ((props: unknown) => OverlayController)
|
|
>
|
|
/**
|
|
* Background-audio controllers for the track names declared in
|
|
* `video.audio(...)`. Each is callable with `start()`/`end()`; for names-only
|
|
* declarations the file, volume, and repeat come from the web app. Empty when
|
|
* none are declared.
|
|
*/
|
|
audio: Record<string, AudioController>
|
|
}
|
|
|
|
const _videoBase = base.extend<
|
|
VideoFixtureOptions & VideoRuntimeFixtures,
|
|
{ recordingFinalizationQueue: WorkerFinalizationQueue }
|
|
>({
|
|
_screenciConfigRecordOptions: [DEFAULT_VIDEO_OPTIONS, { option: true }],
|
|
_screenciConfigRenderOptions: [undefined, { option: true }],
|
|
_screenciRecordOptions: [undefined, { option: true }],
|
|
_screenciRenderOptions: [undefined, { option: true }],
|
|
_screenciLanguage: [undefined, { option: true }],
|
|
_screenciVideoName: [undefined, { option: true }],
|
|
_screenciNarration: [undefined, { option: true }],
|
|
_screenciValues: [undefined, { option: true }],
|
|
_screenciOverlays: [undefined, { option: true }],
|
|
_screenciAudio: [undefined, { option: true }],
|
|
_screenciRecordingLocalize: [undefined, { option: true }],
|
|
_screenciSourceFile: [undefined, { option: true }],
|
|
|
|
language: async ({ _screenciLanguage }, use) => {
|
|
await use(_screenciLanguage)
|
|
},
|
|
|
|
narration: async (
|
|
{
|
|
_screenciNarration,
|
|
_screenciRecordingLocalize,
|
|
_screenciConfigRenderOptions,
|
|
_screenciRenderOptions,
|
|
_screenciSourceFile,
|
|
},
|
|
use,
|
|
testInfo
|
|
) => {
|
|
const { obj: renderOptionsObj } = resolveStudioRenderOptions(
|
|
combineRenderOptionsLayers(
|
|
_screenciConfigRenderOptions,
|
|
_screenciRenderOptions
|
|
)
|
|
)
|
|
await use(
|
|
buildNarrationMarkers(
|
|
_screenciNarration,
|
|
_screenciRecordingLocalize?.languages ?? [],
|
|
narrationVoiceConfigFromRenderOptions(renderOptionsObj),
|
|
undefined,
|
|
// Pre-warm media-cue hashes before recording so a video cue's start()
|
|
// does not pay the file read on the timeline.
|
|
_screenciSourceFile ?? testInfo.file
|
|
)
|
|
)
|
|
},
|
|
|
|
values: async ({ _screenciValues, _screenciLanguage }, use) => {
|
|
await use(
|
|
buildValues(_screenciValues, _screenciLanguage, parseValuesOverrides())
|
|
)
|
|
},
|
|
|
|
overlays: async ({ _screenciOverlays, _screenciLanguage }, use) => {
|
|
await use(buildOverlays(_screenciOverlays, _screenciLanguage))
|
|
},
|
|
|
|
audio: async (
|
|
{ _screenciAudio, _screenciLanguage, _screenciSourceFile },
|
|
use,
|
|
testInfo
|
|
) => {
|
|
// Pre-warm audio-track hashes before recording so a track's start() does not
|
|
// pay the file read on the timeline.
|
|
await use(
|
|
buildAudio(
|
|
_screenciAudio,
|
|
_screenciLanguage,
|
|
_screenciSourceFile ?? testInfo.file
|
|
)
|
|
)
|
|
},
|
|
recordingFinalizationQueue: [
|
|
async ({}, use) => {
|
|
const queue: WorkerFinalizationQueue = []
|
|
await use(queue)
|
|
|
|
if (process.env.SCREENCI_RECORDING !== 'true' || queue.length === 0) {
|
|
return
|
|
}
|
|
|
|
await finalizeDeferredRecordingStops(queue)
|
|
},
|
|
{ scope: 'worker' },
|
|
],
|
|
|
|
browser: async ({ playwright }, use) => {
|
|
const shouldRecord = process.env.SCREENCI_RECORDING === 'true'
|
|
// captureAudio requires isolated capture (a per-worker null sink). It must
|
|
// succeed or the run fails, so a recording never silently ships without the
|
|
// isolated audio it promises.
|
|
const captureAudioRequested = shouldRecord && isCaptureAudioEnabled()
|
|
|
|
// Isolated capture is Linux-only (macOS/Windows cannot isolate the browser's
|
|
// audio from the rest of the machine). Requested on an unsupported platform:
|
|
// fail fast rather than recording silently.
|
|
if (captureAudioRequested && !isScreenAudioSupported()) {
|
|
throw new Error(
|
|
screenAudioUnsupportedMessage() ??
|
|
'[screenci] captureAudio is not supported on this platform.'
|
|
)
|
|
}
|
|
|
|
const audioActive = captureAudioRequested && isScreenAudioSupported()
|
|
|
|
// Give this worker a dedicated null sink so capture is silent on the host,
|
|
// isolated from other apps, and safe under parallel workers. The browser is
|
|
// routed into it via PULSE_SINK and the recorder captures its monitor.
|
|
let sink: NullSink | null = null
|
|
if (audioActive) {
|
|
// Requires `pactl` and a reachable pulse server (PulseAudio or PipeWire);
|
|
// the pulseaudio daemon binary itself is not needed. Throws if unavailable.
|
|
await assertScreenAudioCaptureReady()
|
|
sink = await createNullSink(workerSinkName())
|
|
if (!sink) {
|
|
throw new Error(
|
|
`[screenci] captureAudio: could not create an isolated audio sink ` +
|
|
`(\`pactl load-module module-null-sink\` failed). Isolated ` +
|
|
`recording is required, so the run is stopped. ` +
|
|
`See ${SCREEN_AUDIO_DOCS_URL}`
|
|
)
|
|
}
|
|
setActiveCaptureDevice(sink.monitorSource)
|
|
}
|
|
|
|
const launchOptions = getChromiumLaunchOptions(shouldRecord, audioActive)
|
|
const browser = await playwright.chromium.launch(
|
|
sink
|
|
? {
|
|
...launchOptions,
|
|
env: { ...process.env, PULSE_SINK: sink.sinkName },
|
|
}
|
|
: launchOptions
|
|
)
|
|
instrumentBrowser(browser)
|
|
await use(browser)
|
|
if (browser.isConnected()) {
|
|
await browser.close()
|
|
}
|
|
if (sink) {
|
|
await unloadNullSink(sink)
|
|
setActiveCaptureDevice(null)
|
|
}
|
|
},
|
|
|
|
context: async (
|
|
{
|
|
browser,
|
|
_screenciConfigRecordOptions,
|
|
_screenciRecordOptions,
|
|
_screenciVideoName,
|
|
colorScheme,
|
|
locale,
|
|
timezoneId,
|
|
userAgent,
|
|
geolocation,
|
|
permissions,
|
|
extraHTTPHeaders,
|
|
httpCredentials,
|
|
ignoreHTTPSErrors,
|
|
offline,
|
|
storageState,
|
|
baseURL,
|
|
bypassCSP,
|
|
acceptDownloads,
|
|
javaScriptEnabled,
|
|
hasTouch,
|
|
isMobile,
|
|
},
|
|
use,
|
|
testInfo
|
|
) => {
|
|
// Configure browser context. The viewport is derived from recordOptions
|
|
// (with web-editor record-option overrides applied); other Playwright
|
|
// `use` options (colorScheme, locale, storageState, ...) are forwarded so
|
|
// they take effect on the context screenci creates.
|
|
const { base: baseRecordOptions } = resolveStudioRecordOptions(
|
|
combineRecordOptionsLayers(
|
|
_screenciConfigRecordOptions,
|
|
_screenciRecordOptions
|
|
)
|
|
)
|
|
const effectiveRecordOptions = resolveEffectiveRecordOptions(
|
|
baseRecordOptions,
|
|
_screenciVideoName ?? testInfo.title
|
|
)
|
|
const aspectRatio =
|
|
effectiveRecordOptions.aspectRatio ?? DEFAULT_ASPECT_RATIO
|
|
const quality = effectiveRecordOptions.quality ?? DEFAULT_QUALITY
|
|
const dimensions = getDimensions(aspectRatio, quality)
|
|
const shouldRecord = process.env.SCREENCI_RECORDING === 'true'
|
|
|
|
// deviceScaleFactor is intentionally not applied to video: the screencast
|
|
// encoder expects frames at the viewport resolution.
|
|
const context = await browser.newContext(
|
|
buildScreenCIContextOptions({
|
|
dimensions,
|
|
applyLocaleDefault: shouldRecord,
|
|
forwarded: {
|
|
colorScheme,
|
|
locale,
|
|
timezoneId,
|
|
userAgent,
|
|
geolocation,
|
|
permissions,
|
|
extraHTTPHeaders,
|
|
httpCredentials,
|
|
ignoreHTTPSErrors,
|
|
offline,
|
|
storageState,
|
|
baseURL,
|
|
bypassCSP,
|
|
acceptDownloads,
|
|
javaScriptEnabled,
|
|
hasTouch,
|
|
isMobile,
|
|
},
|
|
})
|
|
)
|
|
|
|
instrumentContext(context)
|
|
|
|
try {
|
|
await use(context)
|
|
} finally {
|
|
await context.close()
|
|
}
|
|
},
|
|
|
|
page: async (
|
|
{
|
|
context,
|
|
_screenciConfigRecordOptions,
|
|
_screenciConfigRenderOptions,
|
|
_screenciRecordOptions,
|
|
_screenciRenderOptions,
|
|
recordingFinalizationQueue,
|
|
_screenciLanguage,
|
|
_screenciValues,
|
|
_screenciVideoName,
|
|
_screenciSourceFile,
|
|
_screenciRecordingLocalize,
|
|
},
|
|
use,
|
|
testInfo
|
|
) => {
|
|
// Only record when explicitly enabled (record command)
|
|
const shouldRecord = process.env.SCREENCI_RECORDING === 'true'
|
|
// Apply web-editor record-option overrides so the capture, serialized
|
|
// recordOptions, and viewport all use the effective values.
|
|
const { base: baseRecordOptions } = resolveStudioRecordOptions(
|
|
combineRecordOptionsLayers(
|
|
_screenciConfigRecordOptions,
|
|
_screenciRecordOptions
|
|
)
|
|
)
|
|
const { obj: renderOptionsObj } = resolveStudioRenderOptions(
|
|
combineRenderOptionsLayers(
|
|
_screenciConfigRenderOptions,
|
|
_screenciRenderOptions
|
|
)
|
|
)
|
|
const recordOptions = resolveEffectiveRecordOptions(
|
|
baseRecordOptions,
|
|
_screenciVideoName ?? testInfo.title
|
|
)
|
|
// Per-language passes use a unique test title (so each gets its own
|
|
// recording directory) but share one `videoName` so they group as language
|
|
// versions of one video. Plain videos fall back to the test title.
|
|
const videoName = _screenciVideoName ?? testInfo.title
|
|
// Every recording is web-editable: render/record options are always marked
|
|
// studio so the app knows it may override them.
|
|
const recorder = new EventRecorder(
|
|
renderOptionsObj,
|
|
recordOptions,
|
|
{
|
|
renderOptions: true,
|
|
recordOptions: true,
|
|
// Web-owned language set: stamped into metadata.studio.languages so the app
|
|
// knows this video may have languages added/rendered from Studio.
|
|
languages: _screenciRecordingLocalize?.studioOwned ?? false,
|
|
},
|
|
// Action-parameter provenance for this video (values and their
|
|
// explicit/default provenance, straight from code).
|
|
new ActionParamCollector()
|
|
)
|
|
// Declared `values` fields (and the active language's seeds) emitted once at
|
|
// recording start so the backend/Studio learn them.
|
|
const valuesDeclaration = buildValuesDeclaration(
|
|
_screenciValues,
|
|
_screenciLanguage
|
|
)
|
|
// Asset paths are authored relative to the user's script. Playwright reports
|
|
// `testInfo.file` as the builder module that registered the test, so prefer
|
|
// the script path captured at the call site.
|
|
const testFilePath = _screenciSourceFile ?? testInfo.file
|
|
// In per-language mode each pass records one language; this filters cue
|
|
// translations and stamps metadata so the upload becomes a single language
|
|
// version. `null` (shared / single-language) keeps every language.
|
|
recorder.setActiveLanguage(_screenciLanguage ?? null)
|
|
// Stamp the full declared language set (regardless of the `--languages`
|
|
// render filter), but only when a set was explicitly declared. A plain video
|
|
// with the implicit `['en']` default records no availableLanguages, so the
|
|
// app does not treat it as a managed language set. The app unions this across
|
|
// a video's recordings to know every code-defined language even when only a
|
|
// subset was rendered this run.
|
|
if (_screenciRecordingLocalize?.explicit === true) {
|
|
recorder.setAvailableLanguages(
|
|
_screenciRecordingLocalize.availableLanguages ?? []
|
|
)
|
|
}
|
|
|
|
if (!shouldRecord) {
|
|
const page = await context.newPage()
|
|
const runtimeContext = createScreenCIRuntimeContext({
|
|
recorder,
|
|
page,
|
|
testFilePath,
|
|
recordOptions,
|
|
renderOptions: renderOptionsObj,
|
|
activeLanguage: _screenciLanguage ?? null,
|
|
})
|
|
bindStillCaptureToPage(page)
|
|
await setupMouseTracking(page, recorder)
|
|
if (resolveDisableAnimations(recordOptions.disableAnimations, 'video')) {
|
|
await installAnimationDisabling(page)
|
|
}
|
|
await installRedactController(
|
|
page,
|
|
runtimeContext.redact,
|
|
recordOptions.redact
|
|
)
|
|
recorder.start()
|
|
await withActiveRecordingContext({
|
|
runtimeContext,
|
|
page,
|
|
recorder,
|
|
valuesDeclaration,
|
|
fn: async () => {
|
|
await use(page)
|
|
},
|
|
})
|
|
await page.close()
|
|
return
|
|
}
|
|
|
|
// Get video options
|
|
const aspectRatio = recordOptions.aspectRatio ?? DEFAULT_ASPECT_RATIO
|
|
const quality = recordOptions.quality ?? DEFAULT_QUALITY
|
|
const fps = recordOptions.fps ?? DEFAULT_FPS
|
|
const encoder = recordOptions.encoder ?? DEFAULT_VIDEO_ENCODER
|
|
const dimensions = getDimensions(aspectRatio, quality)
|
|
|
|
const directoryName = escapeFileSystemPathSegment(testInfo.title)
|
|
|
|
// Create directory path: .screenci/[video-title]/
|
|
const videoDir = join(process.cwd(), '.screenci', directoryName)
|
|
|
|
// Delete old directory if it exists (start fresh)
|
|
try {
|
|
await rm(videoDir, { recursive: true, force: true })
|
|
} catch {
|
|
// Ignore errors if directory doesn't exist
|
|
}
|
|
|
|
// Create the directory
|
|
await mkdir(videoDir, { recursive: true })
|
|
|
|
// Video output path - always use recording.mp4
|
|
const videoPath = join(videoDir, 'recording.mp4')
|
|
|
|
// Create page FIRST to ensure browser window is rendered
|
|
const page = await context.newPage()
|
|
const runtimeContext = createScreenCIRuntimeContext({
|
|
recorder,
|
|
page,
|
|
testFilePath,
|
|
recordingDir: videoDir,
|
|
recordOptions,
|
|
renderOptions: renderOptionsObj,
|
|
activeLanguage: _screenciLanguage ?? null,
|
|
})
|
|
await setupMouseTracking(page, recorder)
|
|
if (resolveDisableAnimations(recordOptions.disableAnimations, 'video')) {
|
|
await installAnimationDisabling(page)
|
|
}
|
|
await installRedactController(
|
|
page,
|
|
runtimeContext.redact,
|
|
recordOptions.redact
|
|
)
|
|
|
|
// Navigate to blank page to ensure window is ready and rendered
|
|
await page.goto('about:blank')
|
|
|
|
// Wait for browser window to be fully rendered before starting recording
|
|
// This prevents black screen captures
|
|
await page.waitForTimeout(resolveRecordingTimingDuration(1500))
|
|
|
|
const screenRecorder = await startScreencastRecording(
|
|
page,
|
|
videoPath,
|
|
fps,
|
|
quality,
|
|
aspectRatio,
|
|
encoder
|
|
)
|
|
|
|
await positionMouseAtViewportCenter(page, dimensions)
|
|
await screenRecorder.start()
|
|
// Mark the moment the video recording actually begins after the cursor is positioned.
|
|
recorder.start()
|
|
|
|
const captureVolume = resolveCaptureAudioGain(recordOptions.captureAudio)
|
|
// captureAudio is Linux-only. On macOS/Windows skip capture entirely (a
|
|
// warning was already emitted at run start, and the worker fixture emits one
|
|
// at the end) rather than writing a silent or whole-machine track.
|
|
const audioSupported = isScreenAudioSupported()
|
|
// The recording browser is launched once per worker (audio mode is decided
|
|
// then from the root-level enableCaptureAudio switch), before this per-video
|
|
// recordOptions is known. If a video requests captureAudio without that
|
|
// switch on, the browser was launched muted on the legacy headless shell and
|
|
// the captured track would be silent. Fail loudly instead of writing silence.
|
|
if (
|
|
audioSupported &&
|
|
captureRequestedButNotEnabled(captureVolume, isCaptureAudioEnabled())
|
|
) {
|
|
throw new Error(
|
|
`[screenci] "${videoName}" sets captureAudio but enableCaptureAudio is ` +
|
|
`not turned on. Add "enableCaptureAudio: true" at the top level of ` +
|
|
`your screenci config so the recording browser launches in audio mode ` +
|
|
`(it is decided once per worker, before a video's options are known). ` +
|
|
`See ${SCREEN_AUDIO_DOCS_URL}`
|
|
)
|
|
}
|
|
const audioCapturePath = join(videoDir, 'screen-audio.wav')
|
|
const audioCapture: ScreenAudioCapture | null =
|
|
captureVolume > 0 && audioSupported && isCaptureAudioEnabled()
|
|
? startScreenAudioCapture(audioCapturePath)
|
|
: null
|
|
|
|
// Wrap `page.screenshot()` only now, AFTER the screen recorder has started.
|
|
// The recorder captures a baseline frame via `page.screenshot()` inside
|
|
// `screenRecorder.start()`; wrapping earlier intercepted that internal call
|
|
// and leaked a spurious `screenshot` still into `.screenci/`. Restored in the
|
|
// `finally` below so the recorder's pause/finalize stays native too.
|
|
const restoreStillCapture = bindStillCaptureToPage(page)
|
|
|
|
try {
|
|
await withActiveRecordingContext({
|
|
runtimeContext,
|
|
page,
|
|
recorder,
|
|
valuesDeclaration,
|
|
fn: async () => {
|
|
await use(page)
|
|
|
|
// Do not end video abruptly.
|
|
await sleep(POST_VIDEO_PAUSE)
|
|
recorder.addSleep(POST_VIDEO_PAUSE, 'postVideo')
|
|
},
|
|
})
|
|
} finally {
|
|
restoreStillCapture()
|
|
await screenRecorder.pause()
|
|
recordingFinalizationQueue.push({ recorder: screenRecorder })
|
|
|
|
if (audioCapture !== null) {
|
|
try {
|
|
const captured = await audioCapture.stop()
|
|
recorder.addScreenAudioTrack({
|
|
path: captured.path,
|
|
fileHash: captured.fileHash,
|
|
volume: captureVolume,
|
|
repeat: false,
|
|
})
|
|
} catch (err) {
|
|
logger.warn(
|
|
`captureAudio: failed to capture audio track and it will be omitted. ` +
|
|
`${err instanceof Error ? err.message : String(err)}`
|
|
)
|
|
}
|
|
}
|
|
|
|
await page.close()
|
|
|
|
if (testInfo.status === 'passed') {
|
|
const configDir = process.env.SCREENCI_CONFIG_DIR ?? process.cwd()
|
|
await recorder.writeToFile(
|
|
videoDir,
|
|
videoName,
|
|
relative(configDir, testFilePath)
|
|
)
|
|
}
|
|
}
|
|
},
|
|
})
|
|
|
|
type VideoType = TestType<
|
|
PlaywrightTestArgs &
|
|
PlaywrightTestOptions &
|
|
VideoFixtureOptions &
|
|
VideoRuntimeFixtures &
|
|
PlaywrightWorkerArgs &
|
|
PlaywrightWorkerOptions,
|
|
PlaywrightWorkerArgs & PlaywrightWorkerOptions
|
|
>
|
|
|
|
type VideoArgs = Omit<PlaywrightTestArgs, 'page'> & {
|
|
page: ScreenCIPage
|
|
} & PlaywrightTestOptions &
|
|
VideoFixtureOptions &
|
|
VideoRuntimeFixtures &
|
|
PlaywrightWorkerArgs &
|
|
PlaywrightWorkerOptions
|
|
|
|
type VideoBody = (args: VideoArgs, testInfo: TestInfo) => void | Promise<void>
|
|
|
|
/** Conditional overloads shared by skip / fixme / fail */
|
|
type ConditionalOverloads = ((
|
|
condition?: boolean,
|
|
description?: string
|
|
) => void) &
|
|
((condition?: boolean, callback?: () => string) => void)
|
|
|
|
interface VideoCallSignatures {
|
|
/**
|
|
* Declares a ScreenCI video recording test.
|
|
*
|
|
* Tests automatically record browser interactions as video. The viewport is
|
|
* configured based on `recordOptions.aspectRatio` and `recordOptions.quality`.
|
|
*
|
|
* @param title - Test title (used as the video filename)
|
|
* @param body - Test body containing page interactions to record
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* import { video } from 'screenci'
|
|
*
|
|
* video('Product demo', async ({ page }) => {
|
|
* await page.goto('https://example.com')
|
|
* await page.click('text=Get Started')
|
|
* // Video recorded at 16:9 1080p, 60fps (defaults)
|
|
* })
|
|
* ```
|
|
*
|
|
* @example
|
|
* Configure video options:
|
|
* ```ts
|
|
* video.recordOptions({ aspectRatio: '16:9', quality: '2160p', fps: 60 })(
|
|
* '4K demo',
|
|
* async ({ page }) => {
|
|
* await page.goto('https://example.com')
|
|
* }
|
|
* )
|
|
* ```
|
|
*/
|
|
(title: string, body: VideoBody): void
|
|
|
|
/**
|
|
* Declares a ScreenCI video recording test with additional details.
|
|
*
|
|
* Tests automatically record browser interactions as video. The viewport is
|
|
* configured based on `recordOptions.aspectRatio` and `recordOptions.quality`.
|
|
*
|
|
* @param title - Test title (used as the video filename)
|
|
* @param details - Additional test configuration (tags, annotations, etc.)
|
|
* @param body - Test body containing page interactions to record
|
|
*
|
|
* @example
|
|
* Using tags:
|
|
* ```ts
|
|
* import { video } from 'screenci'
|
|
*
|
|
* video('Checkout flow', {
|
|
* tag: '@critical',
|
|
* }, async ({ page }) => {
|
|
* await page.goto('https://example.com/checkout')
|
|
* })
|
|
* ```
|
|
*
|
|
* @example
|
|
* Using annotations:
|
|
* ```ts
|
|
* video('Sign up', {
|
|
* annotation: { type: 'issue', description: 'https://github.com/...' },
|
|
* }, async ({ page }) => {
|
|
* await page.goto('https://example.com/signup')
|
|
* })
|
|
* ```
|
|
*/
|
|
(title: string, details: TestDetails, body: VideoBody): void
|
|
}
|
|
|
|
/**
|
|
* Recursive interface so `.only`, `.skip`, `.fixme`, `.fail`, and `.slow`
|
|
* all surface `page: ScreenCIPage` instead of the raw Playwright `page: Page`.
|
|
*
|
|
* Properties that don't receive per-test fixture args (`describe`, `beforeAll`,
|
|
* `afterAll`, `use`, `extend`, `step`, `info`, `expect`, `setTimeout`) are
|
|
* forwarded from `VideoType` unchanged.
|
|
*/
|
|
interface Video extends VideoCallSignatures {
|
|
/** Run only this test. */
|
|
only: Video
|
|
/** Skip this test, with optional conditional overloads. */
|
|
skip: Video & ConditionalOverloads
|
|
/** Mark this test as fixme, with optional conditional overloads. */
|
|
fixme: Video & ConditionalOverloads
|
|
/** Mark this test as expected to fail, with optional conditional overloads. */
|
|
fail: Video & ConditionalOverloads
|
|
/** Mark this test as slow, with optional conditional overload. */
|
|
slow: Video & ((condition?: boolean, description?: string) => void)
|
|
|
|
/**
|
|
* Record one localized pass per language. By default each language is recorded
|
|
* in its own pass with the browser `locale` set from the language; the body
|
|
* receives the active `language`, the `narration` markers, and the `values`
|
|
* fields. Pass `mode: 'shared'` for a single capture shared across languages.
|
|
*
|
|
* Chainable with `.each(...)` / `.languages(...)`.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* video
|
|
* .narration({ en: { intro: 'Welcome.' }, fi: { intro: 'Tervetuloa.' } })
|
|
* .values({ en: { heading: 'Dashboard' }, fi: { heading: 'Hallinta' } })(
|
|
* 'Tutorial',
|
|
* async ({ page, language, narration, values }) => {
|
|
* await page.goto('/' + language)
|
|
* await page.getByTestId('heading').fill(values.heading ?? '')
|
|
* await narration.intro()
|
|
* }
|
|
* )
|
|
* ```
|
|
*/
|
|
narration: MediaBuilder<VideoArgs>['narration']
|
|
|
|
/** Declare on-screen values fields (array = blank names, object = code values). */
|
|
values: MediaBuilder<VideoArgs>['values']
|
|
|
|
/** Declare overlays (array = blank names, object = code values/factories). */
|
|
overlays: MediaBuilder<VideoArgs>['overlays']
|
|
|
|
/** Declare background-audio tracks (array = blank names, object = code values). */
|
|
audio: MediaBuilder<VideoArgs>['audio']
|
|
|
|
/**
|
|
* Declare the recorded language set / capture mode. The web app owns the set;
|
|
* pass an array `['en', 'fi']` or an options object to seed it, or call with
|
|
* no argument to leave the set entirely to the web app.
|
|
*/
|
|
languages: MediaBuilder<VideoArgs>['languages']
|
|
|
|
/**
|
|
* Declare capture options (aspect ratio, quality, fps, ...). A flat object
|
|
* applies to every language; a language-major object (`{ default, de, ... }`)
|
|
* overrides per language. Values stay editable in the web app.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* video.recordOptions({
|
|
* default: { aspectRatio: '16:9' },
|
|
* de: { aspectRatio: '4:3' },
|
|
* })('Landing', async ({ page }) => {
|
|
* await page.goto('/')
|
|
* })
|
|
* ```
|
|
*/
|
|
recordOptions: MediaBuilder<VideoArgs>['recordOptions']
|
|
|
|
/**
|
|
* Declare render options (framing, narration voice, output, ...). A flat
|
|
* object applies to every language; a language-major object overrides per
|
|
* language. Values stay editable in the web app.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* video.renderOptions({
|
|
* default: { narration: { voice: { name: voices.Ava } } },
|
|
* fi: { narration: { voice: { name: voices.Onni } } },
|
|
* })('Tutorial', async ({ page }) => {
|
|
* await page.goto('/')
|
|
* })
|
|
* ```
|
|
*/
|
|
renderOptions: MediaBuilder<VideoArgs>['renderOptions']
|
|
|
|
/**
|
|
* Produce a separate video per variant (viewport, theme, ...). Each variant
|
|
* has its own video identity and history. Chainable with `.languages(...)`.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* video.each([
|
|
* { key: 'mobile', recordOptions: { aspectRatio: '9:16' } },
|
|
* { key: 'desktop', recordOptions: { aspectRatio: '16:9' } },
|
|
* ])('Landing', async ({ page }) => {
|
|
* await page.goto('/')
|
|
* })
|
|
* ```
|
|
*/
|
|
each: MediaBuilder<VideoArgs>['each']
|
|
|
|
/** Run a hook before each test in the current suite. */
|
|
beforeEach(
|
|
inner: (args: VideoArgs, testInfo: TestInfo) => Promise<void> | void
|
|
): void
|
|
beforeEach(
|
|
title: string,
|
|
inner: (args: VideoArgs, testInfo: TestInfo) => Promise<void> | void
|
|
): void
|
|
/** Run a hook after each test in the current suite. */
|
|
afterEach(
|
|
inner: (args: VideoArgs, testInfo: TestInfo) => Promise<void> | void
|
|
): void
|
|
afterEach(
|
|
title: string,
|
|
inner: (args: VideoArgs, testInfo: TestInfo) => Promise<void> | void
|
|
): void
|
|
|
|
// Pass-through: these don't take per-test fixture args that include `page`.
|
|
describe: VideoType['describe']
|
|
beforeAll: VideoType['beforeAll']
|
|
afterAll: VideoType['afterAll']
|
|
use: VideoType['use']
|
|
extend: VideoType['extend']
|
|
step: VideoType['step']
|
|
info: VideoType['info']
|
|
expect: VideoType['expect']
|
|
setTimeout: VideoType['setTimeout']
|
|
}
|
|
|
|
/**
|
|
* ScreenCI video recording test fixture.
|
|
*
|
|
* Extended Playwright test that automatically records browser interactions as video.
|
|
* Configure capture/render options with `video.recordOptions(...)` /
|
|
* `video.renderOptions(...)` per video, or project-wide in your config file.
|
|
*
|
|
* @example
|
|
* Basic usage:
|
|
* ```ts
|
|
* import { video, voices } from 'screenci'
|
|
*
|
|
* // Voice is a render option; the narration text is declared with video.narration.
|
|
* video.renderOptions({ narration: { voice: { name: voices.Ava } } }).narration({
|
|
* en: {
|
|
* homepage: 'User navigates to homepage.',
|
|
* signup: 'Clicks the sign up button.',
|
|
* },
|
|
* })('Tutorial', async ({ page, narration }) => {
|
|
* await page.goto('https://example.com')
|
|
* await narration.homepage()
|
|
*
|
|
* await page.click('text=Sign up')
|
|
* await narration.signup()
|
|
* })
|
|
* ```
|
|
*/
|
|
export const video = _videoBase as unknown as Video
|
|
|
|
// Attach the chainable fan-out builders. They register through the same test
|
|
// instance, so per-language / per-variant passes inherit every video fixture.
|
|
const _rootBuilder = createVideoBuilder<VideoArgs>(
|
|
_videoBase as unknown as Parameters<typeof createVideoBuilder>[0]
|
|
)
|
|
video.narration = _rootBuilder.narration
|
|
video.values = _rootBuilder.values
|
|
video.overlays = _rootBuilder.overlays
|
|
video.audio = _rootBuilder.audio
|
|
video.languages = _rootBuilder.languages
|
|
video.recordOptions = _rootBuilder.recordOptions
|
|
video.renderOptions = _rootBuilder.renderOptions
|
|
video.each = _rootBuilder.each
|