Skip to content

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.

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:

  • resolve is called for every changed file, not only for files matching paths. paths widens what gets watched; it does not narrow what you are asked about. So a plugin sees the component a story renders, too — and answers null for it.
  • null is the safe answer. A resolve that returns too few ids produces a watch mode that silently misses regressions, which is worse than one that re-runs too much. Return null whenever 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.
  • watch is 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-pages captures URLs served by a dev server it knows nothing about, so it ships no watch and 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.