Skip to content

Writing Video Tests

Writing Video Tests

Video scripts vs. Playwright tests

ScreenCI video scripts look like Playwright tests but have a different goal. A Playwright test verifies that your app behaves correctly — it makes assertions and fails when something is wrong. A ScreenCI video script records what your app looks like — it drives the browser to produce a polished video.

This means:

  • You generally don’t write expect() assertions (though nothing stops you).
  • You control pacing — add waitForTimeout() to let the UI settle before the next action.
  • You care about what the viewer sees, not just whether the test passes.

Everything in Playwright’s page API works as-is. ScreenCI extends it — it does not replace it.

A video script file must end in .video.ts (or .video.js, .video.mts, etc.). Each call to video() produces one recorded video:

videos/demo.video.ts
import { video } from 'screenci'
video('Product demo', async ({ page }) => {
await page.goto('https://example.com')
await page.click('text=Get Started')
await page.fill('input[name="email"]', '[email protected]')
await page.click('button[type="submit"]')
})

video() is a thin wrapper around Playwright’s test() — it accepts the same title, optional details object, and async body.


ScreenCIPage — not a plain Page

Inside video(), the page fixture is a ScreenCIPage, not a standard Playwright Page. The difference is intentional:

WhatPlaywright PageScreenCIPage
page.locator()Returns LocatorReturns ScreenCILocator (animated interactions)
page.getByRole()Returns LocatorReturns ScreenCILocator
page.mouseMouse (teleport)ScreenCIMouse (animated bezier-curve moves)
All other page.*Standard PlaywrightSame — unchanged

All standard page methods (goto, waitForURL, waitForLoadState, waitForTimeout, keyboard, screenshot, etc.) work exactly as documented in Playwright’s API.

ScreenCILocator — animated interactions

ScreenCILocator wraps Playwright’s Locator and overrides the interaction methods to produce realistic on-screen cursor and typing animations:

MethodPlaywright LocatorScreenCILocator
click()Instant click, no visible pathAnimated bezier-curve cursor move, then click
fill()Fills value in one shotTypes character-by-character using pressSequentially
hover()Instant hoverAnimated cursor move, then hover
dragTo()Immediate dragAnimated move → mouseDown → animated drag → mouseUp
selectText()Instant selectionAnimated move, triple-click animation
All othersStandard PlaywrightSame — returns ScreenCILocator to keep the chain typed
video('Settings demo', async ({ page }) => {
await page.goto('/settings')
// fill() types character-by-character — viewer sees each keystroke
await page.locator('#name').fill('Jane Doe')
// click() moves the cursor along a curve before clicking
await page.locator('button[type="submit"]').click()
})

fill() accepts extra options:

await page.locator('#email').fill('[email protected]', {
duration: 1500, // total typing time in ms (default: 1000)
click: 'before', // animate cursor to the field and click before typing
hideMouse: true, // hide the cursor while typing
})

All chaining methods (locator(), getByRole(), filter(), first(), last(), etc.) return ScreenCILocator so the animated behaviour is preserved throughout the chain.


Captions

createCaptions() defines typed voiceover text. At render time ScreenCI generates an AI voiceover (via ElevenLabs) for each caption and syncs it to the recording.

import { video, createCaptions } from 'screenci'
const captions = createCaptions({
intro: "Let's walk through the settings page.",
save: 'Hit save to apply your changes.',
})
video('Settings walkthrough', async ({ page }) => {
await page.goto('/settings')
await captions.intro.start()
await page.waitForTimeout(2000)
await captions.intro.end()
await page.locator('#save').click()
await captions.save.start()
await captions.save.end()
})

.start() — display and move on

Resolves after all words have appeared (0.5 s per word). The caption stays visible until .end() is called. Use this when you want captions to run in parallel with page interactions:

await captions.intro.start()
await page.goto('https://example.com/signup')
await captions.intro.end()

.waitUntil(percent) — time an action to the voiceover

Resolves when the given percentage of the audio has played. Useful for clicking a button exactly when the voiceover mentions it:

await captions.cta.start()
await captions.cta.waitUntil('70%') // wait until 70% of words have appeared
await page.locator('#cta').click() // then click
await captions.cta.end()
ValueResolves when
'0%'Immediately, before any word appears
'50%'After half the words have appeared
'100%'After all words (same as .start())

.end() — end the caption

Call it after every .start() or .waitUntil(). Calling it when no caption is active is a no-op.

Multi-language captions

Pass a language map and TypeScript will enforce that every language has the same keys:

import { createCaptions, voices } from 'screenci'
const captions = createCaptions({
en: {
voice: voices.en.Jude,
captions: { intro: 'Welcome.', save: 'Hit save.' },
},
fi: {
voice: voices.fi.Martti,
captions: { intro: 'Tervetuloa.', save: 'Tallenna.' },
},
})

Missing a translation key in any language is a TypeScript error.


Assets

createAssets() defines image or video overlays that appear on top of the recording at render time. Use them for intro screens, logo bugs, or transition clips.

import { video, createAssets } from 'screenci'
const assets = createAssets({
logo: { path: './logo.png', audio: 0, fullScreen: false, duration: 3000 },
intro: { path: './intro.mp4', audio: 1.0, fullScreen: true },
})
video('Product demo', async ({ page }) => {
await assets.logo // shows logo for 3 s, then auto-hides
await page.goto('/dashboard')
assets.intro.show() // start video overlay (non-blocking)
await page.waitForTimeout(4000)
await assets.intro.hide() // hide manually
})

Image assets require a duration (ms). After that time the asset auto-hides. Calling .hide() before the timer fires cancels it.

Video assets play for their natural length. Call .hide() to stop them early.

await assets.logo is shorthand for await assets.logo.show().


autoZoom

autoZoom() adds a camera zoom that follows interactions. The camera zooms in at the start of the callback and zooms back out when it resolves. All clicks and fills inside drive a pan that keeps the active element centred.

import { video, autoZoom } from 'screenci'
video('Settings demo', async ({ page }) => {
await page.goto('/settings/profile')
await autoZoom(
async () => {
await page.locator('#name').fill('Jane Doe')
await page.locator('#email').fill('[email protected]')
await page.locator('button[type="submit"]').click()
await page.waitForTimeout(600)
},
{ duration: 400, easing: 'ease-in-out', amount: 0.4 }
)
})

autoZoom cannot be nested — calling it inside another autoZoom throws.

Options

OptionTypeDefaultDescription
durationnumber400Zoom-in and zoom-out transition duration in ms
easingstring'ease-in-out'CSS easing for the zoom transitions
amountnumber0.5Fraction of output dimensions visible when zoomed (0–1)

One autoZoom per section

Wrap entire page sections, not individual clicks. The camera zooms in when you start a form and zooms back out when you leave — one smooth motion:

video('Multi-section demo', async ({ page }) => {
await page.goto('/settings/profile')
await autoZoom(
async () => {
await page.locator('#name').fill('Jane')
await page.locator('#email').fill('[email protected]')
await page.locator('button[type="submit"]').click()
await page.waitForTimeout(600)
},
{ duration: 400, easing: 'ease-in-out', amount: 0.4 }
)
await page.goto('/settings/security')
await autoZoom(
async () => {
await page.locator('#password').fill('new-secret')
await page.locator('button[type="submit"]').click()
await page.waitForTimeout(600)
},
{ duration: 400, easing: 'ease-in-out', amount: 0.4 }
)
})

hide

hide() cuts a section from the final video. Any actions inside the callback are invisible to viewers. Use it for logins, page loads, redirects, and any setup the viewer doesn’t need to see.

import { video, hide } from 'screenci'
video('Dashboard demo', async ({ page }) => {
await hide(async () => {
await page.goto('/login')
await page.fill('input[type="email"]', '[email protected]')
await page.fill('input[type="password"]', 'secret')
await page.click('[type="submit"]')
await page.waitForURL('**/dashboard')
await page.waitForLoadState('networkidle')
await page.waitForTimeout(2000)
})
// Video starts here — dashboard is already open and ready
await page.locator('#reports').click()
})

hide cannot be nested — calling it inside another hide throws.

Hide between sections

hide() is also useful between page transitions so the viewer doesn’t watch a loading spinner:

await hide(async () => {
await page.locator('nav a[href="/reports"]').click()
await page.waitForURL('**/reports')
})
await autoZoom(
async () => {
// interact with the reports page
},
{ duration: 400, easing: 'ease-in-out', amount: 0.4 }
)

Regular Playwright code

Because ScreenCIPage preserves the full Page interface, all regular Playwright patterns work exactly as you’d expect:

import { video } from 'screenci'
video('Checkout flow', async ({ page }) => {
await page.goto('/checkout')
await page.waitForURL('**/checkout')
await page.waitForSelector('#cart-summary')
await page.waitForLoadState('networkidle')
await page.keyboard.press('Tab')
await page.keyboard.type('4111111111111111')
await expect(page.locator('#total')).toBeVisible()
await page.screenshot({ path: 'checkout.png' })
})

See Playwright’s full API docs for everything available on page.


Authentication

Use Playwright’s storageState to reuse an authenticated session:

screenci.config.ts
import { defineConfig } from 'screenci'
export default defineConfig({
use: {
storageState: 'auth.json',
},
})

Generate auth.json with a Playwright global setup script.


Output location

.screenci/
<sanitized-test-title>/
recording.mp4 ← the video
data.json ← click and mouse move events
Test titleDirectory name
'Homepage walkthrough'homepage-walkthrough
'Sign up (new user)'sign-up-new-user
'Step 1 & 2 — Login'step-1-2-login

Running without recording

Run scripts without screen capture to verify selectors and logic quickly:

Terminal window
npx playwright test --config=screenci.config.ts

With recording:

Terminal window
SCREENCI_RECORD=true npx playwright test --config=screenci.config.ts