Skip to content

Reusable snippets ​

The imports: block on a spec lets you split reusable parts across files and reference them by name. A snippet can be a single step, a sequence, or even an entire spec authored to be lifted into other specs.

File layout ​

text
specs/
├── checks/
│   ├── login-admin.yml
│   └── assert-login-page.yml
├── flows/
│   ├── checkout.yml
│   └── checkout-success.yml
├── snippets/
│   ├── actions/
│   │   ├── rotate-token.yml
│   │   └── create-entity.yml
│   └── conditional-steps/
│       ├── dismiss-banner.yml
│       └── env-conditional.yml

The directory structure is convention only — cairn walks the spec root recursively, finds every *.yml file, and indexes them by name. The name is the spec's first comment or its filename minus .yml.

Imports block ​

yaml
# specs/flows/checkout.yml
imports:
  - actions/login-admin        # refers to specs/snippets/actions/login-admin.yml
  - actions/rotate-token
  - conditional-steps/dismiss-banner

intent: Sign in, rotate an API token, and confirm the new token works.
outcomes:
  - id: token-rotated
    text: { contains: "Token rotated" }
steps:
  - use: login-admin            # uses the imported snippet
  - use: rotate-token
  - use: dismiss-banner

use: invokes the imported snippet by name within the spec's steps:. An action file can declare its own imports: — paths relative to the action file — and use: them, so login_as_supplier reuses login_base instead of copying its steps:

yaml
# actions/roles/login_as_supplier.yml
version: 1
name: login_as_supplier
imports:
  - ../shared/login_base.yml
steps:
  - use: { action: login_base, vars: { user: supplier@demo.test } }
  - click: { by: role, role: button, name: Supplier view }

An action's use: resolves against its own imports first, then against its importer's. Vars reach a nested action in this order: the inner call's own vars win; then — only for names the nested action does not default — the values its caller saw; then the spec's vars; then the nested action's defaults. An enclosing call never silently overrides a nested action's own default: pass it on explicitly (use: { action: fill_form, vars: { name: "${vars.name}" } }) when that is what you want. The import graph must be a DAG: an import cycle, or a use: cycle reached through the importer's scope, is a parse error, and two files that declare the same action name: are refused. Expansion is recursive, so a run, cairn spec heal and the step-relative file paths all point at the innermost action file, and cairn export playwright --project turns a nested use: into a call to that action's module.

What gets reused ​

The same logic that lives in one spec is the candidate for a snippet:

  • Login flows. "Sign in as an admin" is reused everywhere a flow needs an authenticated session.
  • Token rotation, key rotation, secret reset. Anything where the contract is "the system shows the success toast after I do the dance."
  • Conditional dismissals. Banner dismissal, modal close, and feature-flag dismissals are the same shape in 30 specs. Make them snippets.
  • Form fills with a fixed shape. "Fill name + email + submit" reused across several flows.

Do not lift things that are unique to one flow. The point of a snippet is to encode the contract across many flows, not to factor code.

When you lift something to a snippet ​

  1. The contract (intent + outcomes) should still read sensibly without steps.
  2. The snippet's intent becomes "do the snippet's job" and outcomes becomes "snippet succeeded." The harness consuming the snippet measures success against its own outcomes, not the snippet's.
  3. Refactor in two commits: the first introduces the snippet, the second swaps the inline steps for use:. This makes bisect readable.
  4. If multiple snippets need a shared precondition (e.g. "be on /settings"), put that precondition in a top-level login snippet — don't repeat it.

Snippet vs. action file ​

In early adoption, the term action file and snippet are interchangeable. There is no separate "action file" syntax — a snippet file is just a spec YAML with intent + outcomes + steps. The directory structure (snippets/actions/) is a convention so consumers can grep for them; the runner doesn't care.

Conditional steps ​

Snippets frequently need conditional behavior. The when: predicate makes a step opt-in:

yaml
- when: { env: FEATURE_BANNER, equals: "on" }
- click: { by: { role: button, name: "Dismiss banner" } }

Conditional steps are excellent snippet content because the snippet itself decides whether to run the step based on environment, harness capability, or run-time configuration. The harness consuming the snippet does not need to know.

Iteration ​

When a snippet's contract changes — that's rare but it happens — the contract hash changes. Re-stamp:

bash
cairn spec verify specs/snippets/actions/rotate-token.yml --stamp

Then run all specs that import that snippet to confirm the contract hash drift didn't break any consumer:

bash
cairn run specs --cold-start --format json

Passing a directory (cairn run specs/) walks the spec tree recursively for *.yml in one shot — there is no separate --recursive flag.

Cookbook: building a snippet library ​

A reasonable starting library for a SaaS-ish app:

  • actions/login.yml — sign in flow.
  • actions/logout.yml — sign out, defensively in case the test seeded a session.
  • actions/rotate-token.yml, actions/revoke-token.yml — token lifecycle.
  • actions/seed-entity.yml, actions/delete-entity.yml — entity lifecycle.
  • conditional/dismiss-banner.yml, conditional/dismiss-cookie-banner.yml — first-paint nags.
  • conditional/env-prod-skip.yml — short-circuit when running against prod.

That set covers 80% of the import statements in the rest of the spec tree.

See also ​

Local-first browser specs for coding agents. Released under the MIT License.