npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@variance-authority/scenario

v0.32.0

Published

Record runtime scenarios as AAA state machines and assess variance across witnessed transitions.

Readme

@variance-authority/scenario

Record runtime scenarios as AAA state machines and assess variance across witnessed transitions.

Part of Variance Authority. Point your coding agent at llms.txt.

What this is for

A snapshot test checks that one UI state looks the way it did. It does not check that clicking Delete does the same thing on a page with one article as on a page with two.

This package records that: a named starting state, a fixed sequence of acts, and what the UI looked like after each one — then compares two such recordings and names the first act where they stopped agreeing. Arrange–Act–Assert, kept as data rather than as control flow.

It records and compares. It drives nothing: it opens no browser, fires no click, and renders nothing. Your test harness performs the acts and hands this package the observations. It writes no baseline, no verdict, and no exit code.

Two terms this project uses throughout:

  • A subject is one named UI state you asked for and can ask for again — page-one-article, page-error. Here a subject is the precondition a recording starts from, and arranging it is your harness's job.
  • An observation profile is what the capture surface was able to see at all, independent of what it found: jsdom resolves roles, accessible names and author-declared style but has no layout engine, while chromium adds the resolved cascade, real geometry and pixels. Every frame in one recording must share one profile, so a dimension is never reported unchanged because neither side could see it.

Requirements

Node 22 or newer, and an ESM project: set "type": "module" in your package.json, because this package ships ESM only and has no CommonJS build. npm init -y will not do that for you. The package has no framework, runner or browser peer: it never touches the DOM.

Its one dependency is @variance-authority/core, which supplies the SemanticSnapshot type and the comparison. The example below imports from it directly, so install both.

Record two paths and compare them

npm install --save-dev @variance-authority/scenario @variance-authority/core

The file below is complete. Save it as scenario-demo.ts and run node scenario-demo.ts — Node 22.18 and newer strip the type annotations themselves, so no tsx or ts-node loader is involved. The snapshot helper stands in for your collector: in a real suite each SemanticSnapshot is a normalized capture of a rendered subject that a collector takes and @variance-authority/core normalizes. This package never produces one.

// scenario-demo.ts
import {
  CHROMIUM_PROFILE,
  digestValue,
  environmentKey,
  type SemanticSnapshot,
} from '@variance-authority/core/format';
import {
  assessScenarios,
  defineScenario,
  recordAct,
  startScenario,
} from '@variance-authority/scenario';

const environment = environmentKey({
  profile: CHROMIUM_PROFILE.id,
  engine: 'chromium@1',
  ruleset: 'rules@1',
  allowlist: 'allowlist@1',
  viewport: { width: 800, height: 600, deviceScaleFactor: 1, colorScheme: 'light' },
  fonts: [],
  conditions: {},
  assets: {},
});

// Stands in for a collector capture. Yours arrives already shaped like this.
function snapshot(subjectId: string, text: string): SemanticSnapshot {
  return {
    formatVersion: 1,
    subject: { id: subjectId, kind: 'fixture' },
    profile: CHROMIUM_PROFILE,
    environment,
    renderHash: digestValue({ subjectId, text }),
    structureHash: digestValue({ tag: 'button', text }),
    styleHash: digestValue({ color: 'black' }),
    root: {
      path: '0',
      tag: 'button',
      attributes: {},
      style: { color: 'black' },
      rect: { x: 0, y: 0, width: 100, height: 40 },
      text,
      children: [],
    },
    styleProvenance: [],
    diagnostics: [],
  };
}

const definition = defineScenario('delete-an-article', [{ key: 'delete-first' }]);

function record(
  executionId: string,
  before: SemanticSnapshot,
  after: SemanticSnapshot,
) {
  return recordAct(
    startScenario(
      definition,
      { id: executionId, precondition: before.subject, profile: before.profile.id },
      before,
    ),
    'delete-first',
    after,
  );
}

const oneArticle = record(
  'run-1/one-article',
  snapshot('page-one-article', 'Article A'),
  snapshot('page-one-article', 'No articles'),
);

const twoArticles = record(
  'run-1/two-articles',
  snapshot('page-two-articles', 'Article A, Article B'),
  snapshot('page-two-articles', 'Article B'),
);

console.log(JSON.stringify(assessScenarios(oneArticle, twoArticles), null, 2));

What you get

The printed assessment, abridged — both recordings performed delete-first, and its effect was not the same on the two preconditions:

{
  "arrange": {
    "kind": "measured",
    "variance": {
      "digest": "v1:0b1f7fa959977b2c06b80e4a959d8811",
      "identical": false,
      "bands": ["content"],
      "components": [],
      "unobserved": [],
      "blindSides": [],
      "parting": {
        "slice": "unread",
        "lines": [
          "unread — the page changed and what would explain it was not read",
          "  no framework boundary was read, so nothing can be said about why"
        ]
      }
    }
  },
  "transitions": [
    {
      "act": { "key": "delete-first", "occurrence": 1 },
      "leftEffect": {
        "kind": "measured",
        "variance": { "digest": "v1:ae736247f8a3912d2f858781cd76f576", "bands": ["content"] }
      },
      "rightEffect": {
        "kind": "measured",
        "variance": { "digest": "v1:1d653ffac6b79a4f6c6cdd108fd8708a", "bands": ["content"] }
      },
      "divergence": {
        "kind": "measured",
        "identical": false,
        "left": "v1:ae736247f8a3912d2f858781cd76f576",
        "right": "v1:1d653ffac6b79a4f6c6cdd108fd8708a"
      }
    }
  ],
  "firstDivergence": { "kind": "found", "act": { "key": "delete-first", "occurrence": 1 } },
  "unmatched": []
}

Reading it:

  • arrange compares the two starting observations. The two pages genuinely differ, so it is identical: false.
  • Each entry in transitions shows the left recording's effect, the right one's, and whether they agree. firstDivergence names the earliest act where they did not.
  • bands names the dimension that changed, from a11y, geometry, token, content, texture. blindSides lists bands the profile on a side could not decide at all, so a blind side is never reported as agreement.
  • parting names which input separated the two readings — a component's own retained state, something it received, or neither — and slice: "unread" means no framework boundary was read, so it declines to name one rather than guessing. lines is that reading as sentences.

An effect digest is the digest of the classified variance between an act's before and after snapshot, not the frame's own render hash — the exact content digest of what rendered. The two answer different questions. Editing a shared token changes every frame's render hash, yet leaves the effect digest stable, because the edit is present in an act's before and after frame alike. Changing the delete handler does change the effect digest at that act, because that is the comparison the delta is taken from.

Acts align by (key, occurrence) along their common ordered prefix. An inserted, missing or repeated act is reported in unmatched with its side; later ordinals are not shifted into a plausible pair. A failed observation is recorded with unobserved(diagnostic); it ends the reachable prefix and leaves firstDivergence as unresolved rather than none.

startScenario takes:

| option | required | what it decides | | --- | --- | --- | | id | yes, non-empty | the recording's identity within its run | | precondition | yes | a SubjectRef — the subject id and kind your harness set up — in the role of Arrange | | profile | yes | the observation profile every frame must match | | preconditionLink | no | the already-resolved parent subject and whether it was declared or named; omitted means no link was supplied |

preconditionLink is passed through, not computed. If your harness already resolved that page-error derives from page, pass that result through; this package records it and infers nothing about fixtures, mocks, cookies, routes or flags.

Fold recordings into a state machine

foldScenarios(runs) groups frames by render hash and adds the transitions those recordings witnessed under their act keys. Paths that reach one hash converge on one node. If one state and act produce two destination hashes, both transitions survive and the result reports the divergence. An act that was never observed appears under unknown. Continuing scenario-demo.ts:

import { foldScenarios } from '@variance-authority/scenario';

const machine = foldScenarios([oneArticle, twoArticles]);
console.log(machine.nodes.length, machine.transitions.length, machine.divergences);

The value has nodes, transitions, unknown and divergences. An edge no recording witnessed is simply absent from it. Folding replays nothing — it turns frames you already recorded into a graph.

Retain semantic evidence explicitly

The root entrypoint keeps everything in memory and performs no I/O, so dropping the value drops the recording. Import createScenarioArchive from @variance-authority/scenario/archive when the evidence has to survive the process. Continuing scenario-demo.ts again — await at the top level needs no wrapper, since the file is a module:

import { createScenarioArchive } from '@variance-authority/scenario/archive';

const archive = createScenarioArchive({ root: '.variance/scenarios' });

await archive.put(
  {
    project: 'storefront',
    run: 'run-1',
    scenario: 'delete-an-article',
    execution: 'run-1/one-article',
    precondition: 'page-one-article',
    profile: 'chromium',
    attempt: '1',
  },
  oneArticle,
  {
    retainUntil: '2030-01-01T00:00:00.000Z',
    access: 'the storefront team',
    deletion: 'removed by collectExpired after retainUntil',
    admit: () => ({ kind: 'admitted' }),
  },
);

The address covers project, run, scenario, execution, precondition, profile and attempt. attempt is a label you assign, such as a retry count; the archive never derives it. The policy declares expiry, access and deletion, plus admit, which can refuse a given snapshot. Equal snapshots are stored once by content digest. read returns unobserved for expired or missing evidence, and collectExpired removes expired manifests and any semantic objects no retained manifest references.

Only canonical SemanticSnapshot text and a versioned manifest go into the archive; no screenshots. That text can still contain document text, accessible names, attributes, URLs and source evidence — so if a value must not be retained, admit has to refuse the snapshot, because nothing redacts it after hashing.

createScenarioArchive takes root, the writable directory the archive lives in, and an optional now clock for deterministic expiry decisions; the system clock is the default. Neither option writes anything by itself — only put does.


@variance-authority/scenario is part of Variance Authority — documentation · GitHub · MIT