The adapter is the only app-specific code in the system. The core library never imports from or assumes anything about your app; everything it needs arrives through this interface:
import type { TutorialAdapter } from 'tutorial-forge';
export const myAdapter: TutorialAdapter = {
/** Base URL of the running app. */
baseURL: 'http://localhost:3000',
/**
* Auth, seeding, navigation to the starting screen.
* Runs after page creation, before step 1.
* Recorded, but trimmed from the final video (pre-roll).
*/
async setup(page) {
await page.goto('http://localhost:3000/login');
await page.getByLabel('Email').fill('demo@example.com');
await page.getByLabel('Password').fill(process.env.DEMO_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.getByRole('heading', { name: 'Dashboard' }).waitFor();
},
/** Optional cleanup (delete seeded data, logout). Never in the video. */
async teardown(page) {
await page.goto('http://localhost:3000/logout');
},
};The adapter is the shared auth/seed baseline — every tutorial runs through it. When one tutorial needs a different starting state than another (a clean "create your first event" narrative vs. a fully-seeded one), declare per-tutorial hooks that compose with the adapter:
export default tutorial('Create your first event', steps, {
// Runs after adapter.setup(), before step 1 — per-video state on top of the baseline.
async setup(page, ctx) {
await deleteAllEvents(); // this tutorial wants an empty list
},
// Runs after recording (after step thunks, before adapter.teardown()).
async teardown(page, ctx) {
await deleteAllEvents();
},
});Run order is adapter.setup → tutorial.setup going in, and step onTeardown thunks (LIFO) → tutorial.teardown → adapter.teardown coming out. Tutorials without hooks keep working through the adapter alone.
The teardown chain runs on every exit path of a render — clean finish, a step failure, and a failure inside adapter.setup/tutorial.setup — so data created (and any ctx.onTeardown registered) before a throw is always cleaned up, never leaked into a shared test DB. Each hook is guarded: a teardown that runs against half-built state (e.g. after a setup failure) logs a warning instead of masking the original error. Because of this, write teardown hooks to tolerate partial setup (null-check what you delete).
tutorial.setup and steps usually need what adapter.setup established — the signed-in identity, a seeded id. Return it from adapter.setup and it lands on ctx.state, a per-render bag (scoped to one render, so it's parallel-safe). No module-global handoff, no ! assertions:
interface Seed { steward: Person }
export const adapter: TutorialAdapter<Seed> = {
baseURL: 'http://localhost:3000',
async setup(page) {
const steward = await seedSteward();
await signIn(page, steward);
return { steward }; // → ctx.state
},
async teardown(page, ctx) {
await deletePerson(ctx.state.steward.id);
},
};
// Read it in the tutorial — typed via tutorial<Seed>/step<Seed>:
export default tutorial<Seed>('Send a broadcast', [
step<Seed>('Create an event.', async (page, ctx) => {
await createEvent(page, ctx.state.steward);
const id = new URL(page.url()).pathname.split('/').pop()!;
ctx.state.eventId = id; // steps can stash live-created ids…
ctx.onTeardown(() => deleteEvent(id)); // …for their own cleanup
}),
], {
async setup(page, ctx) {
await seedEventFor(ctx.state.steward); // reads what the adapter established
},
});A teardown thunk's return value is awaited and discarded, so value-returning one-liners work directly: ctx.onTeardown(() => Promise.all(people.map((p) => deletePerson(p.id)))).
Which hooks run on each path:
| Path | step onTeardown |
tutorial.teardown |
adapter.teardown |
|---|---|---|---|
| Render — clean finish | ✓ | ✓ | ✓ |
| Render — a step fails | ✓ (for completed steps) | ✓ | ✓ |
Render — adapter.setup/tutorial.setup throws |
✓ (registered before the throw) | ✓ | ✓ |
preview <step> (any outcome) |
✓ | ✓ | ✓ |
doctor --setup |
✓ | n/a (no tutorial) | ✓ |
preview reaches partial, mid-tutorial state yet still runs the full chain — it's run repeatedly while tuning a step, so leaving the adapter seed behind would quietly fill the DB. (This is why teardown hooks must tolerate partial setup.)
For data a step creates mid-tutorial, register cleanup inline with ctx.onTeardown() instead of tracking it in the adapter:
step('Create an event.', async (page, ctx) => {
await page.getByRole('button', { name: 'New event' }).click();
await page.getByLabel('Name').fill('Launch Party');
await page.getByRole('button', { name: 'Create' }).click();
ctx.onTeardown(() => deleteEventByName('Launch Party')); // torn down deterministically
});Thunks run in reverse registration order, so the last thing created is the first cleaned up. Like teardown, onTeardown failures log a warning and never fail the render.
- End setup on the screen step 1 expects. The video starts (minus
leadInMs) right where setup leaves off — wait for that screen to be fully rendered before returning. - Seed deterministic state. Same inputs should produce the same video. Create fixture data with fixed names/dates; avoid "3 minutes ago"-style relative content where you can.
- Run your app yourself. The pipeline does not start your dev server; do that before
tutorial-forge render(or in your CI job before the render step). - Keep secrets in env vars. The adapter is plain code in your repo; read credentials from the environment, as in any e2e test.
- Teardown failures are non-fatal, and must tolerate partial setup. They log a warning and the render still succeeds — teardown runs after the manifest is final, and also after a failed setup, so null-check what you delete.
- Verify setup before a full render.
tutorial-forge doctorchecks the app is reachable; add--setupto actually runadapter.setuponce and tear it down. It catches the "reachable but pointed at the wrong database" case — a green reachability check followed by a guaranteed sign-in failure — before you wait out a whole render.
By default the CLI renders one tutorial × language at a time. Because the record phase mostly waits (each step holds in real time for its narration), the machine is near-idle during it — so rendering a set of tutorials is much faster in parallel. Opt in with --render-concurrency <n> (or renderConcurrency in forge.config.ts); the default is 1 (serial).
Concurrency > 1 only works if your adapter is parallel-safe, because each concurrent render runs its own setup/teardown against your app at the same time. The contract:
- Isolate seed data per render. Concurrent
setupcalls must not collide on shared state. Give each render its own namespace — a per-worker database/schema, a unique tenant or account, or seed records keyed so they can't clash — rather than seeding into one shared space. If two renders seed and tear down the same rows, they'll corrupt each other. - Don't assume a single live browser/page. Each render drives its own browser; adapters that reach for a module-global page or client will break. Use
ctx.state(see above) for per-render handoff, never a shared singleton. - Make teardown idempotent and scoped. It already must tolerate partial setup; under concurrency it must also only remove its own render's data.
If you're not sure your adapter meets this, leave concurrency at 1 — it's the safe default. (TTS synthesis is already safely parallelized within a render via ttsConcurrency, independent of this.)
If one render fails, the command stops scheduling new ones and exits non-zero, but renders already in flight run to completion first (their logs may interleave after the error) — so a failed batch can still leave a few finished videos behind.