Sign-in, servers and test data
To log in to your app before each screenshot, start the servers it talks to, or make records for it to show, add
these parts to facetshot.config.ts:
| Part | What it does |
|---|---|
| Personas | sign in as a user, with cookies, headers, storage or a login flow |
| Services | start your API, a mock server or anything else your app talks to |
| Test data | make the records a page needs before the first screenshot, and remove them afterwards |
The page ends with a complete example of a web app with its own API. How facetshot builds and serves the web app itself is in Web apps.
Personas
Section titled “Personas”A persona is a signed-in user. Define personas under web.personas, choose the usual one with web.persona, and
choose another for some screenshots with a variant:
web: { personas: { admin: { cookies: [{ name: 'session', value: 'dev-admin' }] }, viewer: { headers: { 'X-Dev-User': 'viewer' } }, customer: { login: 'flows/login.yaml', ttl: 720 }, saved: { storageState: '.auth/customer.json' } }, persona: 'admin'},variants: { 'as-viewer': { persona: 'viewer' }}facetshot /billing --as light,as-viewerThe fields are listed in the configuration reference. A few things to know:
cookiesgo to the app’s own host name unless you give adomain, so an API on another host doesn’t get them. They need a known app address (web.baseURLorweb.serve).headersare sent with every request the page makes, including requests to other sites.loginis a flow that signs in. It runs once in a fresh browser context, and the signed-in state is saved to.facetshot/auth/<persona>.jsonand reused until it is older thanttlminutes. If the flow fails, the run says that signing in failed and which step stopped. Its steps hold any password as written, so use a test account, and keep the flow out of git if the password matters.- If a persona has both
storageStateandlogin, the file is used. - Personas apply to web pages and Storybook. Electron windows don’t get them yet (Desktop apps).
Saving a storage state file
Section titled “Saving a storage state file”To sign in once by hand and save the result, use Playwright’s code generator. It opens a browser; sign in, then close it:
bunx playwright codegen --save-storage=.auth/user.json https://staging.example.com/loginThen use storageState: '.auth/user.json' in a persona. The file holds a live session, so add .auth/ to your
.gitignore. Use a test account, never a real person’s.
Sign-in that only exists in development
Section titled “Sign-in that only exists in development”Many apps sign in a fake local user only in development mode, so a production build can’t sign in locally. One way around this is a separate build mode for screenshots, which keeps the local sign-in and can’t be deployed:
build: { cmd: 'vite build --mode screenshots', inputs: ['src', 'index.html', 'vite.config.ts', 'package.json'] },serve: { cmd: 'vite preview --mode screenshots --port {port} --strictPort', port: 'auto' }In your app, enable the local sign-in when import.meta.env.DEV || import.meta.env.MODE === 'screenshots'. Write that
condition inline where it is used, since bundlers remove unused code reliably only when the constant is right there.
Build that mode without a deployment adapter, so the output can’t be shipped by accident, and add a test that a
normal production build doesn’t contain the local user.
SvelteKit
Section titled “SvelteKit”For a SvelteKit app, list everything the build reads as inputs, and pass the API’s address through an environment
variable the app reads with $env/static/public or import.meta.env:
web: { build: { cmd: 'bunx --bun vite build --mode screenshots', inputs: ['src', 'static', 'svelte.config.js', 'vite.config.ts', 'package.json'], env: { PUBLIC_API_URL: '{services.api.url}' } }, serve: { cmd: 'bunx --bun vite preview --mode screenshots --port {port} --strictPort', port: 'auto' }}For the screenshots build, use an adapter that vite preview can serve (such as @sveltejs/adapter-node), not the
one you deploy with.
Services
Section titled “Services”An API, a mock server or anything else your app talks to goes under services. Each service starts before the first
screenshot and stays up for later runs while the background helper is running:
services: { api: { cmd: 'bun run api', cwd: '../api', port: 4000, health: '/health', reuse: 'http://localhost:4000' }}portis a number, orautofor a free one. Leaving it out meansauto.healthis a path (or full URL) that answers once the service is ready. facetshot waits up tostartTimeoutmilliseconds for it. What counts as an answer, and the defaults, are in the configuration reference.reuse: if this URL already answers, facetshot uses it and starts nothing. The URL itself is asked, nothealth, so give one that answers only when the service is up (for examplehttp://localhost:4000/health).- If the port is taken by something that doesn’t answer
health, the run stops and the message shows how to find that process. - Services start one after another, in the order you list them, so a later service can use an earlier one’s
{services.<name>.url}.
A service doesn’t get the environment of the terminal or agent that asked for the run
(why). To pass one of your variables to it, read
it in the config and put it in env:
services: { api: { cmd: 'bun run api', env: { DATABASE_URL: process.env.DATABASE_URL ?? 'postgres://localhost/app_dev' } }}Placeholders
Section titled “Placeholders”Commands, environment values and targets can contain placeholders, which facetshot fills in once the value is known. Each one is available only from the moment it exists:
| Placeholder | Is | Can be used in |
|---|---|---|
{port}, {url} |
the port of this service or server, and http://localhost:{port} |
that service’s or server’s own cmd and env |
{services.<name>.url} |
a service’s address | later services, builds, the web app’s serve, app env, targets |
a name fixtures.setup returns |
e.g. {project} |
builds, servers, app env, targets |
{web.url} |
where the web app is served | targets and flow addresses only |
{web.url} is set only after the web app’s server is up, which is after services and test data have been made. It
can’t be used in a service’s env (to tell an API which origin to accept, say). Give the web app a fixed port
instead, as in the complete example.
Test data
Section titled “Test data”Pages that show a specific record need one to exist. fixtures.setup is a function that makes test data before the
first screenshot and returns string values for {placeholders} in targets. fixtures.remove deletes one thing it
made:
fixtures: { async setup(ctx) { const response = await fetch(`${ctx.services.api.url}/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: ctx.label }) }); const project = (await response.json()) as { id: number }; ctx.record('project', String(project.id)); return { project: String(project.id) }; }, async remove(item, ctx) { if (item.kind === 'project') await fetch(`${ctx.services.api.url}/projects/${item.id}`, { method: 'DELETE' }); }},aliases: { project: '/projects/{project}' }facetshot project /projectssetup receives a context with these fields:
| Field | What it is |
|---|---|
ctx.runId |
the run’s id |
ctx.label |
facetshot-<run id>. Put it on what you make, so it is easy to find |
ctx.services |
each service’s address: ctx.services.api.url |
ctx.record(kind, id, extra?) |
writes the item to a ledger in .facetshot/ before you return. If the run is killed, the next run removes everything the ledger lists |
ctx.log(message) |
a line in the run’s output |
remove receives { kind, id, extra } as recorded, and the same context.
While the background helper is running, test data is made once, reused by later runs, and removed when the helper
stops. Set reuse: false to make fresh data for every run and remove it when that run ends.
A larger setup is easier to keep in its own file. Import it in the config, since setup must be a function:
import { defineConfig } from 'facetshot';import { removeTestData, makeTestData } from './facetshot/test-data.ts';
export default defineConfig({ fixtures: { setup: makeTestData, remove: removeTestData }});A complete example: web app and API
Section titled “A complete example: web app and API”A Vite app whose API checks the request’s origin. The API must know the web app’s address before either starts, so
both get fixed ports from stablePorts, which also keeps several checkouts of the project from using the same ports
(Settings that differ per checkout):
import { defineConfig, stablePorts } from 'facetshot';
const [webPort, apiPort] = stablePorts(import.meta.dirname, 2);
export default defineConfig({ services: { api: { cmd: 'bun run dev', cwd: '../api', port: apiPort, health: '/health', env: { PORT: '{port}', CORS_ORIGIN: `http://localhost:${webPort}`, DATABASE_URL: process.env.DATABASE_URL ?? 'postgres://localhost/app_screenshots' } } }, web: { build: { cmd: 'bunx --bun vite build', inputs: ['src', 'public', 'index.html', 'vite.config.ts', 'package.json'], env: { VITE_API_URL: '{services.api.url}' } }, serve: { cmd: 'bunx --bun vite preview --port {port} --strictPort', port: webPort }, personas: { member: { cookies: [{ name: 'dev-user', value: 'member' }] } }, persona: 'member' }, fixtures: { async setup(ctx) { const response = await fetch(`${ctx.services.api.url}/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: ctx.label }) }); const { id } = (await response.json()) as { id: number }; ctx.record('project', String(id)); return { project: String(id) }; }, async remove(item, ctx) { await fetch(`${ctx.services.api.url}/projects/${item.id}`, { method: 'DELETE' }); } }, aliases: { project: '/projects/{project}' }});In order, a run starts the API on apiPort and waits for /health, makes a project, builds the web app with the
API’s address in VITE_API_URL (only when an input changed), serves it on webPort, and takes screenshots signed in
as member.