Custom Plugins
Plugins can hook into the capture lifecycle to register new pages, mutate context, or send
notifications. Start by creating a function that returns an object conforming to the plugin API from
@cappa/core.
import type { CappaPlugin } from '@cappa/core';
export function myPlugin(): CappaPlugin { return { name: 'my-plugin', description: 'My plugin description',
/** * Discover all tasks (pages, screenshots, etc.) to be executed * A single task will be later passed to the execute callback */ discover: async () => { return [ { name: 'my-page', url: 'https://example.com', }, ]; }, /** * Initialize the page before executing tasks on that page * This is called once per page before executing tasks on that page * * You may initialize the page with custom logic, e.g. add a custom header, or wait for a specific element to be visible. */ initPage: async (page, screenshotTool) => { return { page, screenshotTool, }; }, /** * Execute a single task * This is called once per task */ execute: async (task, page, screenshotTool) => { await screenshotTool.capture(page, 'my-page', { fullPage: true, viewport: { width: 1024, height: 768 }, }, { saveDiffImage: true, diffImageFilename: 'my-page-diff.png', }); } };}Register the plugin in cappa.config.ts and iterate on the callbacks until they match your use
case. Combine plugins to orchestrate complex capture flows without bloating the core CLI.
Watch support
Section titled “Watch support”cappa capture --watch re-captures on file change. Cappa has no idea which of your tasks a
saved file affects — the plugin does — so a plugin can teach it, through one optional member:
watch: { /** Extra globs, relative to cwd, added to what the watcher watches. */ paths: ['**/*.stories.@(js|jsx|ts|tsx)'], /** * Map one changed file onto the task ids it affects. * `null` means "cannot tell" — re-run everything this plugin owns. */ resolve: (file, tasks) => { const affected = tasks .filter((task) => task.data?.source === file) .map((task) => task.id); return affected.length > 0 ? affected : null; },},Things worth knowing:
resolveis called for every changed file, not only for files matchingpaths.pathswidens what gets watched; it does not narrow what you are asked about. So a plugin sees the component a story renders, too — and answersnullfor it.nullis the safe answer. Aresolvethat returns too few ids produces a watch mode that silently misses regressions, which is worse than one that re-runs too much. Returnnullwhenever the mapping is uncertain — including for a file that looks like one of yours but matches no discovered task, which is what a freshly created source file looks like before the next discovery.watchis optional. A plugin without it contributes its whole task set whenever anything in the watched set changes. That is the right behaviour when tasks have no local source at all:@cappa/plugin-pagescaptures URLs served by a dev server it knows nothing about, so it ships nowatchand re-captures its pages.- Task ids you return must be ids discovery produced. Unknown ids are rejected, which is the same boundary that stops the review UI steering the browser somewhere arbitrary.
@cappa/plugin-storybook implements this against Storybook’s story index, which already records
the importPath of every story: saving a story file re-captures only that file’s stories, and
saving anything else re-runs the plugin.