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

@termwright/test

v0.7.7

Published

Termwright's exact-certified Vitest runner, fixtures, matchers and semantic snapshots

Readme

@termwright/test

The authoring API used by Termwright's native test host: a terminal fixture that owns real PTY sessions, event-driven matchers, and semantic YAML snapshots — the accessibility tree of a terminal app in a reviewable form. Vitest supplies the DSL, transforms, mocks and assertions inside that host; direct Vitest/Jest/node:test execution is not a Termwright product mode.

termwright test, termwright watch and termwright ui all use the same exact runner and persistent host. Native collected IDs, not file/title strings, identify .each, duplicate-title, repeat and retry attempts. Each real try has an AttemptContext, total budget, journal producer and host resource leases.

Tests which need more than one terminal declare the complete group on the test itself. The Native Host atomically admits that group before fixtures run and before the Attempt budget starts, so two-terminal tests cannot deadlock after holding only their first lease:

test.resources({ terminals: 2 })('client and server interoperate', async ({ terminal }) => {
  const [client, server] = await Promise.all([
    terminal.launch({ command: ['node', 'client.js'] }),
    terminal.launch({ command: ['node', 'server.js'] }),
  ]);
  // ...
});

Each declared terminal reserves one PTY, external process, semantic endpoint, one native-host pressure unit, and—unless traceWriters is explicitly set—one trace writer. Exceeding the declared group fails instead of silently falling back to another queue. Because that condition is detected exactly, the error reports the declared, currently allocated and newly requested counts and shows the minimum test.resources({ terminals: N }) declaration needed for intentional concurrent ownership.

Native transport certification may reserve nativeHost: 'exclusive', the full host-pressure envelope, while still declaring its actual terminal count. This keeps high-volume output and process-tree tests exclusive without pretending that they own terminals they never create.

Host-intensive tests which do not launch a terminal, such as compiler or toolchain integration tests, reserve the same envelope directly:

test.resources({ hostPressure: 'exclusive' })('builds through the real toolchain', async () => {
  // ...
});

hostPressure composes with a real terminals count when both the toolchain and PTY are part of one test. It is intentionally exclusive-only; use nativeHost for native transport certification.

Install

pnpm add -D termwright @termwright/test [email protected]

Requires a certified Node LTS line and exact Vitest 4.1.11. ESM only. Run with termwright test; the exact pin is an engine certification surface, not a broad peer-compatibility promise.

Usage

import { fileURLToPath } from 'node:url';
import { test, expect } from '@termwright/test';

const agent = fileURLToPath(new URL('../agent.js', import.meta.url));

test('asks before running a command', async ({ terminal, step }) => {
  const app = await terminal.launch({ command: [process.execPath, agent] });
  await app.waitForText('Permission required');

  await step('approve', async () => {
    await app.press('Enter');
  });

  await expect(app).toHaveText('running: ls -la');
});

This generic path works without a semantic integration. Use semantic locators and visibility matchers only when the selected framework integration publishes the required evidence.

The session closes itself, its working directory is removed, and — when the test fails — its .twtrace archive is kept for the report.

Fixtures

| Fixture | What it gives you | | ------------ | ------------------------------------------------------------------------------------------------------ | | terminal | launch(options) for as many sessions as the test needs, all closed on teardown; sessions; tmpdir | | step | step(title, body) — a marker in the recording, a step in the trace and a section in the report | | termwright | config, private tmpdir, traces, timeout-safe resources, and owned sidecars |

Each test gets a fresh temporary directory (the default cwd) and a minimal environment: only PATH, HOME and friends are inherited, so a stray variable on a laptop cannot change what CI sees. test.step() works too; the step fixture is the form to prefer under test.concurrent, since it is bound to its own test rather than to the most recently started one.

Resource acquisition is registered before its asynchronous factory starts. This means a timeout cannot strand a server that finishes starting while test teardown is already running:

test('uses an API fixture', async ({ termwright }) => {
  const server = await termwright.sidecars.launch({
    command: [process.execPath, 'test/fixtures/api.js'],
    ready: { output: 'listening on', stream: 'stdout' },
  });
  // server.stdout(), server.stderr(), server.pid
});

termwright.resources.defer(), .use(), .acquire() and .child() cover non-process resources. They run once in LIFO order, receive the native timeout through resources.signal, and aggregate cleanup failures.

Skipping when there is no PTY

Sandboxed CI, slim containers and installs without the native prebuild cannot open a pseudo-terminal, and a suite that reports that as a failure is a suite people switch off. The preset ships the probe so no project has to write it:

import { fileURLToPath } from 'node:url';
import { describe } from 'vitest';
import { ptyAvailable, test, expect } from '@termwright/test';

const pty = await ptyAvailable();
const appPath = fileURLToPath(new URL('../app.js', import.meta.url));

describe.skipIf(!pty)('the app', () => {
  test('starts', async ({ terminal }) => {
    const app = await terminal.launch({ command: [process.execPath, appPath] });
    await expect(app).toHaveText('ready');
  });
});

The result is memoized, and TERMWRIGHT_SKIP_PTY=1 forces it to false when you want to skip these suites deliberately.

Options for a file or a suite

The equivalent of Playwright's test.use(), on Vitest's own mechanism:

import { describe } from 'vitest';
import { test, expect } from '@termwright/test';

test.override({ termwrightOptions: { columns: 120, trace: 'on' } });

describe('the wide layout', () => {
  test.override({ termwrightOptions: { columns: 200 } });
  // …tests here get 200 columns; the rest of the file gets 120.
});

Scopeable: command, columns, rows, env, timeouts, trace, failOnLogLevel. They sit between the project configuration and the launch() call:

defineTermwrightConfig()  <  test.override({ termwrightOptions })  <  terminal.launch({ … })

The merge is key by key, which matters more than it sounds: test.override replaces a fixture's whole value, so overriding only trace would drop the project's viewport and environment if the value were taken as-is. env and timeouts merge entry by entry too — overriding one variable or one timeout class keeps the others. command is the exception: an argv is replaced wholly, never concatenated.

Files the program starts with

Declare files the application needs on the launch. They exist in the test's private directory — which is also the program's cwd — before it starts:

import { fileURLToPath } from 'node:url';

const editorPath = fileURLToPath(new URL('../editor.js', import.meta.url));

const app = await terminal.launch({
  command: [process.execPath, editorPath],
  files: {
    'config.json': JSON.stringify({ theme: 'dark' }),
    'notes/todo.md': '- write tests\n',
  },
});

Directories are created as needed, and a Uint8Array is written as bytes. To start from a whole project, copy a template first — the declared files are written over it, so a test can take a fixture project and change only the one file it is about:

await terminal.launch({ template: 'test/fixtures/project', files: { 'config.json': '{}' } });

There is no shared fixtures directory on purpose. Files are declared by the test that needs them, into a directory only that test can see, so no test can depend on what another one left behind. A path that would escape that directory is refused rather than written.

Matchers

All of them are asynchronous — await expect(...) — and the locator ones poll at committed observation boundaries until the expect timeout class runs out.

| Matcher | Subject | | ---------------------------------------------------- | -------------------------------------------------------------------------- | | toBeVisible() | locator | | toBeFocused() | locator | | toHaveState({ disabled: true }) | locator; asserts only the keys you list | | toHaveExtendedState({ deploymentStatus: 'ready' }) | locator; recursively compares only the top-level domain keys you list | | toHaveText('Save' \| /Sav/) | locator (exact, whitespace-normalized) or terminal (substring of the grid) | | toMatchSemanticSnapshot(expected?, { within }) | terminal or SemanticSnapshot | | toMatchCellSnapshot(expected?) | terminal or ScreenSnapshot |

Retrying is what makes them safe right after a screen wait. waitForText() returns when the grid shows the text, but the semantic tree for that frame only becomes observable once its render-commit marker has been paired — including the first tree after the handshake, where semanticTree() is still null while the contract negotiation is pending. Every tree-reading matcher waits through that gap, and a snapshot being written for the first time waits for a tree rather than storing the absence of one.

Two keys in one press() arrive together

press('Tab Space') encodes both chords into a single write, so a program that batches its input handling sees them in one go — the space reaches the element Tab was leaving, not the one it moved to. When the program has to re-render between two keys, send them separately and put the assertion in between:

await app.press('Tab');
await expect(app.getByRole('list', { name: 'Todos' })).toHaveState({ focused: true });
await app.press('Space');

Portable widget state and application state are deliberately different assertions:

await expect(app.getByTestId('deploy-row')).toHaveExtendedState({
  deploymentStatus: 'rolling-out',
  rollout: { regions: ['eu', 'us'], progress: 0.5 },
});

The assertion is not decoration here: it is the wait that lets the program handle the first key before the second arrives, and it costs nothing extra because the matcher already waits on committed revisions. Several chords in one press() are for sequences the program consumes as a unit, like 'Control+K Control+F'.

A failure reads like the driver's own errors: what was expected, what was observed, the timeout that elapsed, the candidate nodes and an excerpt of the screen.

expect(getByRole('button', { name: 'Approve' })).toBeVisible()

Expected: visible
Received: hidden
Timeout:  5000ms

suggestion: narrow the locator with within(), a name option, or select one with first()/nth()
candidates:
  - button "Reject" ref=n4@7
screen:
  Permission required
     Approve    [Reject]

For a domain calculation that is not a built-in matcher, waitUntil() applies the same race-free revision loop and retains the last value for diagnostics:

const request = await app.waitUntil(
  ({ semanticTree }) => semanticTree?.nodes.find((node) => node.testId === 'request')?.extended,
  { until: (state) => state?.status === 'accepted', description: 'accepted request' },
);

Keyboard-only TUIs can expose their real focus ring without a custom sleep loop: await app.focusByTraversal(app.getByTestId('accept'), { next: 'ArrowDown' }). Semantic convenience actions also accept via: 'keyboard' | 'pointer' | 'auto'. When an application enables kitty's progressive keyboard protocol, Termwright answers its query and encodes subsequent key presses from the active flags.

Semantic YAML snapshots

The format is normative in /CONTRACTS.md §YAML snapshots:

- dialog "Permission" [modal]:
    - text "Allow bash to run?"
    - button "Approve" [focused]
    - button /Rej.*/
  • Names are compared after whitespace normalization, may be a /regex/, and may be omitted to match any name.
  • [flags] assert only what they list. !focused asserts the opposite, checked=mixed and level=2 compare a value. Volatile states (scrollOffset, positionInSet, …) are left out of written snapshots unless you ask for { states: 'all' }.
  • '* "Save"' matches any role. It has to be quoted — a bare * opens a YAML alias.
  • A name containing # is written quoted, so the file stays valid YAML.

Two comparison modes, by source

| Source | How it is compared | | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | inline pattern — the argument you write in the test | partial: omitted children are don't-care, unlisted siblings are allowed, flags assert only what they list. Listed children keep their relative order. | | stored file — written into __snapshots__ | strict: the full serialized tree, exact flags. A node or state the app grew fails, which is the whole point of checking in a snapshot. |

So an inline pattern is an assertion about the part you care about, and a file snapshot is a fence around the whole tree. Reach for a pattern in a test about one behaviour, and for a file when you want to be told about any change at all.

Patterns start at the root — scope with within

Matching is anchored at the tree's roots, and Ink, Textual and OpenTUI apps are rooted at application. A pattern that starts at your dialog therefore does not match:

# Does not match an Ink app: the root is `application`, not `dialog`.
- dialog "Permission" [modal]:
    - button "Approve" [focused]

Either spell out the path from the root:

- application:
    - dialog "Permission" [modal]:
        - button "Approve" [focused]

…or, usually better, scope to the container and assert what is inside it:

await expect(app).toMatchSemanticSnapshot(
  `
    - button "Approve" [focused]
    - button /^Rej/
  `,
  { within: app.getByRole('dialog') },
);

within takes a locator, excludes the node itself from the pattern, and is re-resolved on every attempt — a re-render that mints new node ids does not invalidate the scope. Use { rootId } instead when you want the node itself to be the top level of the snapshot.

Semantic and cell snapshots are separate oracles on purpose: a semantic snapshot can pass on a blank screen, because the adapter publishes a tree nobody painted. An important end-to-end test asserts both.

┌─ 60×3 ─────────────────────────────────────────────────────┐
│Permission required                                         │
│   Approve    [Reject]                                      │
│last: ACTIVATED reject                                      │
└────────────────────────────────────────────────────────────┘

Where snapshots live, and how they are updated

Called without an argument, both snapshot matchers store the value in __snapshots__/<test file>.tw-semantic.yaml (or .tw-cells.yaml), one literal block per assertion, keyed by test name.

Vitest's --update has two states; the contract asks for three. The mode is resolved as:

| Source | Mode | | -------------------------------------------------------------- | ------------------------------------------ | | TERMWRIGHT_UPDATE_SNAPSHOTS=all | rewrite every snapshot, even matching ones | | TERMWRIGHT_UPDATE_SNAPSHOTS=changed, or vitest -u | write missing, overwrite mismatching | | TERMWRIGHT_UPDATE_SNAPSHOTS=missing, or a plain run | write missing; a mismatch fails | | TERMWRIGHT_UPDATE_SNAPSHOTS=none, --update=none, or CI | never write; a missing snapshot fails |

Snapshots nobody claims any more

Rename or delete a test and its stored snapshot stays behind. On the first test of each file the preset compares the file's keys against the tests that file declares, and:

  • in changed / all it removes the orphans;
  • in any other mode it leaves them alone and the host records them in the run diagnostics. It never fails the run — an orphaned snapshot is housekeeping, and a red run would only teach people to stop renaming tests.

"Declares" is the important word: a test skipped by describe.skipIf(!pty) is still declared, so its snapshots survive on a machine that skips it. Pruning against the tests that merely ran would delete your E2E baselines the first time CI ran without a pseudo-terminal.

config.updateSnapshots overrides all of it. Because a stored snapshot is compared strictly, changed rewrites it on any textual difference — review the diff the way you would review the code that caused it.

On CI a missing snapshot fails. Vitest turns updating off when CI is set, and this preset follows: a baseline that appears during the run would make CI green by writing the very thing it was supposed to check. Commit snapshots from a local run instead. A test whose subject is writing a snapshot should pin updateSnapshots in its own configuration rather than inherit the environment's.

Application logs

A program's own log is evidence the screen does not show. Point the session at a log file, or let an instrumented adapter publish structured records, and the test can query both the same way:

import { fileURLToPath } from 'node:url';

const editorPath = fileURLToPath(new URL('../editor.js', import.meta.url));

test('saves the file', async ({ terminal }) => {
  const app = await terminal.launch({
    command: [process.execPath, editorPath],
    logs: [{ path: 'var/editor.log', label: 'editor' }],
  });

  await app.getByRole('button', { name: 'Save' }).activate();

  await expect(terminal).toHaveLogged({ level: 'info', message: /saved in \d+ms/ });
  expect(terminal.logs.filter({ minLevel: 'warn' })).toEqual([]);
});

terminal.logs answers all(), filter(query), text(query) and clear(). A query narrows by level (one or several), minLevel, source ('file' | 'adapter'), label, logger, message (substring or pattern) and sessionId. text() renders entries without timestamps, sequence numbers or revisions, so it is stable enough to put in a snapshot:

info starting up
warn storage: disk almost full free=12
[editor] plain line from the file

toHaveLogged polls like the other matchers, so asserting right after an action is safe, and it prints the last entries when nothing matched — usually enough to see why.

A structured record is counted once per session, keyed by its seq. The driver refuses a repeated sequence before it becomes an event, so a duplicate can only come from this side — collectLogs on a harness the fixtures already subscribed pools the same event twice — and one error reported as two would be a bug in the counting, not in the program under test. File lines carry no sequence and are never deduplicated: two identical lines in a log file are two lines.

An error nobody asserted on fails the test

By default a test that passes while the program logged an error fails anyway. This is the assertion nobody writes: clicking through a flow while the program logs error: failed to save is not a passing test, it is a test that did not look.

The test passed, but the program logged 1 record at level error or above:
  error db: save failed

Assert on them with expect(terminal).toHaveLogged({ level: ... }), or turn the check off:
  for one test:   terminal.failOnLogLevel(false)
  for the suite:  defineTermwrightConfig({ failOnLogLevel: false })

When log records never reached the test — dropped by the adapter, or refused over budget — the failure says how many, because the list it prints is only what arrived and calling that the whole story would misrepresent the evidence. terminal.logs.lostRecords() exposes the same number. A record the driver refused as a duplicate is not a loss and is not counted: the record it repeated did arrive.

Two things this deliberately does not do. It never fails on a file line: a followed file yields text, not levels, and guessing severity from the word "error" would fail tests over a URL. And it never fires on a test that already failed — the assertion that failed is the story.

Raise or lower the bar with failOnLogLevel: 'warn' | 'fatal' | false in the config, or per test with terminal.failOnLogLevel(...).

Logs from a mounted component

For a harness the fixtures did not launch — a mountInk component, say — subscribe yourself and the matcher finds the collection from the harness:

const app = await mountInk(<App />, { logs: [{ path: 'var/app.log' }] });
collectLogs(app);
await expect(app).toHaveLogged({ source: 'file', message: 'saved' });

A mount does not capture console.* by default, and that default is right: a mounted component shares the runner's process, so its console is literally Vitest's — capturing it would file the runner's output, and other tests' output, as the component's log. Follow a file instead, or use launchInkFixture when process-owned console behavior is genuinely part of the application under test.

When the program dies

A crashed program does not fail a test in any useful way: the assertion that notices it reports a timeout, and the panic that explains it scrolls past. When a session the test launched died unexpectedly, the preset appends what the driver remembers to the failure message:

expect(getByRole('dialog')).toBeVisible()

Expected: visible
Received: hidden
Timeout:  5000ms

Process crashed
  exited with code 1 after 812ms
  screen tail (last 15 of 31 lines):
    Error: ENOENT: no such file or directory, open 'config.toml'
        at Object.readFileSync (node:fs:1234:20)
  last input: key "c" at 640ms
  full trace: termwright-report/traces/opens-the-dialog-4-0.twtrace

A clean exit is not a crash, and neither is a session the test closed or signalled — that judgement lives in the driver, so the preset shows a section only when there is something to explain. The same data reaches the HTML report, where @termwright/trace renders it with the recording.

Treat a crash section like a screenshot: it is the terminal's output verbatim, so whatever the program printed on its way out — secrets included — is in it.

Configuration

// termwright.config.ts
import { fileURLToPath } from 'node:url';
import { defineTermwrightConfig, XTERM_PALETTE } from '@termwright/test';

const appPath = fileURLToPath(new URL('./app.js', import.meta.url));

export default defineTermwrightConfig({
  columns: 100,
  rows: 30,
  command: [process.execPath, appPath],
  trace: 'retain-on-failure', // 'on' | 'retain-on-failure' | 'off'
  outputDir: 'termwright-report',
  timeouts: { expect: 5_000, action: 5_000 },
  profiles: {
    ci: { trace: 'on', palette: XTERM_PALETTE },
  },
});
// vitest.setup.ts
import { configureTermwright } from '@termwright/test';
import config from './termwright.config.js';

configureTermwright(config);

TERMWRIGHT_PROFILE=ci selects a profile. A profile's palette pins the 16 ANSI colors and the TERM/COLORTERM a launched program sees, which is what makes color assertions and cell snapshots stable between a laptop and CI.

Building your own fixtures

test is Vitest's own test.extend, so the way to make a suite terse is to compose on top of it rather than wait for an option:

import { test as base, expect } from '@termwright/test';
import type { TerminalHarness } from '@termwright/driver';

const test = base.extend<{ app: TerminalHarness }>({
  app: async ({ terminal }, use) => {
    const app = await terminal.launch({ files: { 'config.json': '{}' } });
    await app.waitForText('ready');
    await use(app);
    // Still inside the terminal fixture: the session is alive here, so a
    // fixture that logs out or asserts a final state can still do it.
  },
});

test('saves on ctrl-s', async ({ app }) => {
  await app.press('Control+s');
  await expect(app).toHaveText('saved');
});

The preset's fixtures stay injectable next to yours ({ app, terminal, step }), teardown runs inside-out, and the types flow through — this package pins all three in composition.test.ts.

Coming from Cypress

| Cypress | Here | | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | cy.fixture('user.json') and the shared fixtures/ directory | launch({ files }) / launch({ template }), declared per test into its own directory | | custom commands (Cypress.Commands.add) | a fixture composed with test.extend, as above | | beforeEach that logs in | the same, but as a fixture: it also tears down, and only the tests that ask for it pay for it |

Diagnostic retries and reports

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { termwrightRetry } from '@termwright/test/config';

export default defineConfig({
  test: {
    setupFiles: ['./vitest.setup.ts'],
    retry: termwrightRetry({ ci: 0, local: 0 }),
  },
});

termwrightRetry returns the retry value consumed by Termwright's embedded Vitest engine; it does not rerun the whole suite. TERMWRIGHT_RETRIES means additional diagnostic attempts (2 means at most three total attempts). Keep the checked-in certifying configuration at zero; enable a non-zero value only for an explicit diagnostic run. Product execution always goes through termwright test. If -- --retry=2 is used as a diagnostic experiment, attempts retain their distinct identities, journals and resources; fail-then-pass is classified flaky and remains a non-zero certification result.

termwright test, termwright watch and termwright ui load the project’s Vitest/Vite configuration inside the certified native host. Optional human or HTML reporters configured there are composed with Termwright’s structured projection; they never own RunId, attempt identity or canonical persistence. The host transaction persists attempts and retained traces. Use termwright report --trace … for an explicit standalone HTML projection.

Development

pnpm build && pnpm typecheck && pnpm test

The end-to-end suite drives the driver's semantic fixture over a real PTY and guards itself with the same ptyAvailable() this package exports (TERMWRIGHT_SKIP_PTY=1 skips it explicitly).