Reusable snippets
The imports: block on a spec lets you split reusable parts across files and call them by name. A snippet can be a single step or a sequence of steps, packaged as an action file.
File layout
specs/
├── checks/
│ └── assert-login-page.yml
├── flows/
│ ├── checkout.yml
│ └── checkout-success.yml
└── snippets/
└── actions/
├── login-admin.yml
├── rotate-token.yml
├── create-entity.yml
└── conditional/
├── dismiss-banner.yml
└── env-conditional.ymlThe layout is convention only. Two rules matter: cairn run <dir> skips folders named actions/ (and names starting with _), so keep reusable files under actions/; and an import is a file path, not a lookup by name.
Each action file needs a snake_case name: (^[a-z][a-z0-9_]*$). use: calls the action by that name, not by its file name.
Imports block
# specs/flows/checkout.yml
version: 1
name: checkout_rotate_token
imports:
- ../snippets/actions/login-admin.yml # relative to this file; .yml required
- ../snippets/actions/rotate-token.yml
- ../snippets/actions/conditional/dismiss-banner.yml
intent: Sign in, rotate an API token, and confirm the new token works.
outcomes:
- id: token_rotated
description: the rotated token is confirmed
verify:
text: { contains: "Token rotated" }
steps:
- use: login_admin # uses the imported snippet
- use: rotate_token
- use: dismiss_bannerEach imported file declares its own name: login-admin.yml has name: login_admin, rotate-token.yml has name: rotate_token, and dismiss-banner.yml has name: dismiss_banner.
use: invokes the imported snippet by name within the spec's steps:. Import paths are resolved relative to the file that imports them, and cairn does not add .yml or look for files by name, so a bare actions/login-admin fails with ENOENT. An action file can declare its own imports: (also relative to the action file) and use: them, so login_as_supplier reuses login_base instead of copying its steps:
# snippets/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
- Give the action a
description:and declare what the caller must pass ininputs:(required: truewhen the action has novars:default; aninputs.<name>.defaultmust equal itsvars:default), so its contract reads without opening the steps. - Action files have no
intent:oroutcomes:. The calling spec'soutcomes:decide whether the run succeeded, so a snippet's job is to leave the page in the state the next step expects. - Refactor in two commits: the first introduces the snippet, the second swaps the inline steps for
use:. This makes bisect readable. - 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. top-level spec
A snippet file is an action file: version, name, steps, and optionally description, vars, inputs and imports. It is validated strictly, so intent and outcomes are rejected in an action file (unknown key(s) "intent", "outcomes"). Only a top-level spec carries intent and outcomes. 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:
vars:
FEATURE_BANNER: "${env.FEATURE_BANNER:-off}"
steps:
- click: { by: role, role: button, name: "Dismiss banner" }
when: { var: FEATURE_BANNER, equals: "on" }when.var reads a vars: value (the spec's, the config's or a --var override), not the process environment. Reach the environment through ${env.X} in vars:, as above.
Conditional steps are excellent snippet content because the snippet itself decides whether to run the step based on its vars, harness capability, or run-time configuration. The harness consuming the snippet does not need to know.
Iteration
When a snippet changes, every spec that imports it changes behavior. Lint the specs that import it, then run them:
cairn spec lint specs/flows/checkout.yml
cairn run specs --cold-start --format jsoncairn spec verify --stamp writes a contract hash into top-level specs only; it rejects action files.
Passing a directory (cairn run specs/) walks the spec tree recursively for *.yml in one shot, skipping actions/ folders and _-prefixed names — there is no separate --recursive flag.
Cookbook: building a snippet library
A reasonable starting library for a SaaS-ish app, under specs/snippets/:
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.actions/conditional/dismiss-banner.yml,actions/conditional/dismiss-cookie-banner.yml— first-paint nags.actions/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
- Authoring — what makes a contract survive across months
- Steps and Verifiers — the typed vocabularies
- Configuration — config keys