An open API service indexing awesome lists of open source software.

https://github.com/qoretechnologies/qlip

A CLI project for capturing and filtering stories.
https://github.com/qoretechnologies/qlip

Last synced: about 1 month ago
JSON representation

A CLI project for capturing and filtering stories.

Awesome Lists containing this project

README

          

# Qlip

Qlip is a Storybook screenshot capture tool. Add it to your project and every story automatically gets a screenshot after its test finishes. Use `screenshot()` inside a play function to capture intermediate states.

## Choosing your integration

Qlip ships **two integration paths**. They produce identical screenshot bundles and upload to the same server — pick by tooling preference, not project size.

| Your situation | Path | Why |
|---|---|---|
| Greenfield, ≤150 stories | **Vitest plugin** (default config) | One extra dep. Screenshots happen as a side effect of `yarn test:stories`. |
| 150+ stories / heavy components | **Vitest plugin (memory-safe config)** | Add `pool: 'forks' + maxForks: N` — see [Memory-safe config](#memory-safe-config-for-large-storybooks). |
| Can't be on Vitest 4 / want zero-Vitest capture | **Runner** (`qlip-serve-and-test`) | Decoupled from consumer's Vitest version. |
| Already running `@storybook/test-runner` | **Runner** | Add one `postVisit` hook to the test-runner you already have. |

The full comparison + when-to-switch guide is in [`design/INTEGRATION_PATHS.md`](./design/INTEGRATION_PATHS.md). The runner architecture is in [`design/RUNNER.md`](./design/RUNNER.md).

## Install

```bash
npm install --save-dev @qoretechnologies/qlip
# Plus, only for the runner path:
npm install --save-dev @storybook/test-runner
```

## Vitest setup

```ts
import { defineConfig } from 'vitest/config';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import { playwright } from '@vitest/browser-playwright';
import { qlipVitestPlugin } from '@qoretechnologies/qlip';

const dirname =
typeof __dirname !== 'undefined'
? __dirname
: path.dirname(fileURLToPath(import.meta.url));

export default defineConfig({
test: {
projects: [
{
extends: true,
plugins: [
storybookTest({
configDir: path.join(dirname, '.storybook'),
}),
qlipVitestPlugin(),
],
test: {
name: 'storybook',
browser: {
enabled: true,
headless: true,
provider: playwright({}),
instances: [{ browser: 'chromium' }],
},
setupFiles: ['.storybook/vitest.setup.ts'],
},
},
],
},
});
```

## Runner setup (for large Storybooks)

Use this **instead of** the Vitest plugin if your suite is past the
Vitest-browser-mode memory envelope (see [Choosing your integration](#choosing-your-integration)
above).

1. Add `.storybook/test-runner.ts`:

```ts
import type { TestRunnerConfig } from '@storybook/test-runner';
import { getStoryContext } from '@storybook/test-runner';
import { qlipCapture } from '@qoretechnologies/qlip/test-runner';

const config: TestRunnerConfig = {
async postVisit(page, context) {
await qlipCapture(page, context, { getStoryContext });
},
};
export default config;
```

2. Run it:

```bash
# Build storybook, serve it on a free port, capture every story
# through @storybook/test-runner, upload as one build:
yarn build-storybook
QLIP_UPLOAD_URL=https://qlip.example.com \
QLIP_PROJECT=my-app \
yarn qlip-serve-and-test --shards 8 --continue-on-failure
```

Flags worth knowing:

- `--shards ` — run N test-storybook invocations sequentially with `--shard k/N`. Each shard is a fresh subprocess; memory resets between shards. Default `1`.
- `--continue-on-failure` — don't stop the loop on a non-zero shard exit. Right default for visual-regression demos where a failed play function doesn't invalidate the captured screenshot.
- `--storybook-static ` — point at an existing static Storybook dir (default `./storybook-static`).
- `--url ` — skip the static server, capture against an already-running Storybook.
- Anything after `--` is forwarded to `test-storybook` (e.g. `-- --maxWorkers 2`).

`qlip-serve-and-test --help` for the full list.

## Authenticating uploads

When the qlip-server is started with `UPLOAD_TOKEN` set, every
upload must carry `Authorization: Bearer `. Both integration
paths read this token from the **`QLIP_UPLOAD_TOKEN`** env var (the
Vitest plugin via `vitest.config.ts`, the runner CLIs via the same
env or an explicit `--token` flag). Against a localhost dev server
with `UPLOAD_TOKEN` unset, leave it blank — uploads are accepted
without a header.

There are **two kinds of token** you can put in `QLIP_UPLOAD_TOKEN`,
and qlip treats them identically — it just forwards the string as a
bearer. The difference is server-side scope:

| Token | Looks like | Scope | When to use |
|-------|-----------|-------|-------------|
| **Per-project** (recommended) | `qlt_…` (44 chars) | Exactly one project; uploads to any other project 403 | CI pipelines. Mint one per project from the dashboard's **Project settings → API tokens**; revoke it without disrupting other projects. |
| **Super-admin** (`UPLOAD_TOKEN`) | whatever you set the env to | Every project, all scopes | Bootstrapping, or a single shared CI that uploads to many projects. The legacy/escape-hatch token; keep it secret. |

Precedence is simple because qlip only sends one token: whatever
value is in `QLIP_UPLOAD_TOKEN` (or `--token`) is the one used. Pick
the **narrowest token that works** — a per-project `qlt_…` token for
a single-project CI, the super-admin token only when one uploader
genuinely spans projects. A per-project token must match the
`QLIP_PROJECT` you're uploading to, or the server returns `403`.

```bash
# CI uploading to one project — scoped per-project token:
QLIP_UPLOAD_URL=https://qlip.example.com \
QLIP_PROJECT=my-app \
QLIP_UPLOAD_TOKEN=qlt_xxxxxxxx… \
yarn test
```

See `qlip-server`'s `design/API.md §0` (auth model) and `§18`
(token CRUD) for the server side, and `design/UPLOAD.md` here for
the upload protocol.

### CI checkout: use `fetch-depth: 0` for accurate baselines

qlip auto-detects the commit ancestry (via `git rev-list`) and, on PR
builds, the base branch (via `$GITHUB_BASE_REF`) and sends them with
every upload. The server walks the ancestry to find the nearest prior
build to diff against. A **shallow** clone — `actions/checkout`'s
default — only fetches the tip commit, so that walk has nothing to
follow and the server falls back to the project's default branch,
producing noisier diffs. Fetch full history:

```yaml
- uses: actions/checkout@v4
with:
fetch-depth: 0
```

qlip prints a one-time warning when it detects a shallow clone. It
never fails the upload over this — accuracy degrades, the run still
succeeds.

## Manual screenshots inside play

Use the existing **"Logged In"** story as a real-world example:

```ts
import { screenshot } from '@qoretechnologies/qlip';

export const LoggedIn = {
play: async (ctx) => {
// interactions and assertions...
await screenshot(ctx, 'after-login');
},
};
```

## Parameters

Configure screenshots per story via `parameters.qlip`:

```ts
export const LoggedIn = {
parameters: {
qlip: {
skip: false,
viewport: { width: 1280, height: 720 },
disableAnimations: true,
pauseAnimationsAtEnd: false,
captureOnError: false,
waitForIdleMs: 300,
maxWaitForIdleMs: 2000,
ignoreElements: ['.toast', '[data-qlip-ignore]'],
},
},
};
```

Options precedence:

1. Explicit options passed to `screenshot()`
2. `parameters.qlip`
3. Plugin defaults

## Plugin options

```ts
qlipVitestPlugin({
outputDir: './qlip/screenshots',
viewport: { width: 1280, height: 720 },
disableAnimations: false,
pauseAnimationsAtEnd: false,
captureOnError: false,
waitForIdleMs: 300,
maxWaitForIdleMs: 2000,
ignoreElements: [],
});
```

`waitForIdleMs` waits for DOM mutations to settle before taking a screenshot. This is especially useful for animation libraries like `react-spring` that update inline styles via `requestAnimationFrame`, which bypasses CSS-based animation disabling. Increase it if you still catch mid-transition frames, or lower it for faster runs when your UI is static. `maxWaitForIdleMs` caps the wait so stories with continuously changing UI still complete.

`ignoreElements` lets you provide CSS selectors to mask before capture. Qlip draws solid overlays on matching elements so layout stays intact while visual diffs ignore those regions.

## Memory-safe config for large Storybooks

If your Storybook has more than ~150 stories, or any stories that mount a heavy app shell (dashboards, multi-pane editors, etc.), the **default Vitest browser-mode** can OOM — it spawns one worker per CPU core, each with its own BrowserContext, and on a 12-core machine that's easily 20+ GB peak RAM.

The fix is **two extra lines** in your storybook project's test config — switch the pool to forks and cap concurrency:

```ts
test: {
name: 'storybook',
browser: { enabled: true, headless: true, provider: playwright({}), instances: [{ browser: 'chromium' }] },
// ↓ The memory-safe knobs ↓
pool: 'forks',
poolOptions: {
forks: { maxForks: 3, minForks: 1, isolate: true },
},
setupFiles: ['.storybook/vitest.setup.ts'],
}
```

Each fork peaks at ~3 GB RSS during a heavy story's chromium render, so the rule of thumb is `maxForks ≤ (free_RAM_GB - 4) / 3`:

| Environment | Suggested `maxForks` |
|---|---|
| GitHub Actions Linux runner (~7 GB) | 1 |
| 16 GB Mac (M1/M2 base) | 2 |
| 24 GB Mac (M4 Pro / M3 Pro) | 3 |
| 32 GB+ workstation | 4–6 |

Verified on a real consumer (qorus-ide, 90 story files / 643 tests, Vitest 2.1.9, Storybook 8.5): **7 min wall time, 7.9 GB peak RSS, full suite completed with no OOM**. Full background in [`design/INTEGRATION_PATHS.md`](./design/INTEGRATION_PATHS.md#memory-safe-config-for-large-storybooks).

## Output layout

```
./qlip/screenshots/
/
stories/
auto/--.png
manual/----.png
error/----qlip-auto-error-capture.png
manifest.json
```

- `buildId` defaults to `YYYYMMDD-HHmmss`
- `parameters.qlip.skip === true` disables all captures for that story

## Manifest

Each run writes `manifest.json` inside the build folder with tool metadata, defaults, stats, and per-screenshot entries (auto + manual).