Skip to content

How Far Along Are the Tests? ​

A Claude Code mod that shows a live progress bar and an ETA while Vitest and Playwright run, and every file and prompt you need to build your own

A Claude Code session with a band above the prompt showing Playwright at 1847 of 5288 tests, 35%, ETA 26:08, and a finished Vitest run


The Problem ​

WarpCad, my browser CAD project, has a big test suite. The unit tests are quick: 287 files, about 6,300 tests, done in under 20 seconds. The end-to-end suite isn't. It runs every Playwright spec against three browser engines at three viewport sizes, which adds up to more than 5,000 tests, and a full run takes most of an hour.

Most of those runs are started by Claude Code, not by me. On purpose, both test runners are set to print as little as possible. Vitest uses its agent reporter and Playwright uses a coding-agent reporter, and both print failures and a summary and nothing else, because every line of test output costs tokens. That's the right call for the agent. For me it meant staring at a spinner for 40 minutes with no idea whether the run was 10% done or 90% done, or stuck.

So I asked for a fix, in one sentence:

When the tests are running, I don't get any feedback at all. I'd like a mod that will show me how far the tests have gone and an eta to finish.

Mid-run, it looks like this:

▶ playwright ■■■■■■■□□□□□□□□□□□□□ 1847/5288 tests · 35% · 14:02 elapsed · ETA 26:08
✓ vitest ■■■■■■■■■■■■■■■■■■■■ 287/287 files · 100% · finished in 0:19
[Hide]

It's a band that sits above the Claude Code prompt while a run is going, plus a short playwright 1847/5288 ~26:08 entry in the status line. A run that finishes stays on screen, dimmed, for 30 seconds and then goes away. It works whether the tests were started by Claude, by /run-todos, or by me in another terminal.

How It Works ​

A Claude Code mod can't watch a Bash command's output while it's running, so the mod can't count test results itself. The test runners have to report their own progress. The design has two halves joined by a folder of small JSON files:

vitest ──► scripts/test-progress/vitest.mjs ─────┐
                                                 ├──► .test-progress/vitest.json
playwright ──► scripts/test-progress/playwright.mjs ──► .test-progress/playwright.json
                                                 │
                     ~/.claude/mods/test-progress ◄──┘  polls every 1s
                       ├── band above the prompt
                       └── status line entry

The project half is two custom reporters, one for each runner, that share a ~60-line writer. Both runners announce the total before the first test runs (Vitest's onTestRunStart and Playwright's onBegin), and that's what makes an ETA possible. Each reporter then writes one file:

json
{ "runner": "playwright", "state": "running", "unit": "tests",
  "done": 1847, "total": 5288, "failed": 0,
  "startedAt": "2026-10-20T23:42:01.224Z", "updatedAt": "2026-10-20T23:56:03.512Z" }

The mod half lives in ~/.claude/mods/test-progress. A session.start hook sets a one-second timer that reads every *.json file in .test-progress/, works out elapsed time and an ETA, and saves the rows to the mod's state. A ui.render hook for the AbovePrompt slot draws the band from that state.

The pieces are deliberately simple, and a few of the details were worth getting right:

  • A reporter must never fail a test run. Every write is wrapped in try/catch and the error is thrown away. Progress is best-effort.
  • Write to a temp file, then rename. The mod polls once a second and would sooner or later read a half-written file. A rename is atomic, so the mod always sees the old file or the new one, never a mix. The mod also skips any file it can't parse and tries again on the next poll.
  • Vitest counts files and Playwright counts tests. Vitest knows its spec files up front but not how many tests are in each, so its unit is files. Playwright knows every test, including each project in the matrix, so its unit is tests. A retried Playwright test counts once, when its last attempt ends, so retries don't push the count past the total.
  • The ETA is plain average pace: elapsed × (total − done) / done. It's null until the first unit finishes and shows as … until then. Nothing smarter is needed. Over a 5,000-test run the average settles quickly.
  • Stale files get ignored. A finished run lingers 30 seconds. A file still marked running that hasn't been written for 10 minutes is a run that got killed, so the mod drops it too.
  • State is only written when it changes, so an idle band doesn't redraw every second.

What Went Wrong on the Way ​

Claude wrote both halves in about two minutes, copying the polling pattern from a todo-progress mod I already had. Getting it to actually show up took longer, and every snag taught me something about how mods load.

Declining the hot-reload prompt. When a mod is written mid-session, Claude Code asks whether to enable hot reloading for it. I said no without really reading it, then typed /test-progress, which isn't a command. The mod has no command. It appears on its own when a run is going. I asked for it to load every time, and Claude bumped the version so the prompt would come back.

Making it permanent. A mod written during a session lands in a folder named after that session's ID. For it to load in every session, it has to be copied somewhere stable and listed in CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json. Claude tried both steps, and Claude Code's permission classifier blocked both as self-modification. That's the right behavior: an agent shouldn't be able to quietly change what loads into its own future sessions. Claude stopped and gave me the two commands, and I ran them myself.

"Are the tests running? If so why am I not seeing the mod?" Yes, they were, and there were two reasons I couldn't see it. The run had started nine minutes before the reporter was added to playwright.config.js, and Playwright reads its config once at startup. And the session I was in was already open before CLAUDE_CODE_PLUGIN_DIRS was set, so it never loaded the mod.

--reporter=line turns it off. A few hours later the band was missing again. This run had been started with --reporter=line on the command line. In both Vitest and Playwright, a CLI --reporter flag replaces the reporter list in the config. It doesn't add to it. The old comment in my Playwright config even recommended --reporter=list as an override, and that's what had led Claude astray. So I asked:

update the config and testing instructions so you always load the test-progress

That changed the comments in both configs, the /test and /tdd commands, and added one rule to CLAUDE.md. I haven't had the problem since. If you skip that step, Claude will eventually pass --reporter and your band will go dark without telling you.

It Became Load-Bearing ​

About a week later I turned off CI on push for WarpCad and replaced it with a local pre-push git hook that refuses to push main unless a full unit run and a full e2e run passed on the exact code being pushed. The hook needed a record of what ran, on what code, and whether the run was filtered. The progress reporters already sat at the right spot in both runners, so they got a second job. When a run ends, they also write a summary (pass/fail counts, failing test names, a fingerprint of src/, and whether it was a full run or a filtered one) to .todo-progress/_runs.json, which the hook reads.

A small comfort feature turned into the thing the release gate depends on. The "never fail the run" rule carried over too. The recorder swallows its own errors, and a run that saw the source tree change halfway through is stored as moved, which doesn't count toward anything.


Build Your Own ​

This works for any project that uses Vitest (3 or later, for the onTestRunStart/onTestModuleEnd reporter API) or Playwright. I built it with Claude Code 2.1.295, and the mod API it uses may change between versions. The code below is the original, simpler version, without the push-gate recorder.

The Fast Way: Ask Claude ​

In a project with Claude Code, invoke the plugin-authoring skill and paste this. It's my original one-liner plus everything I learned afterward:

When tests are running I get no feedback. Build me a mod that shows how far a test run has gone and an ETA to finish.

  • Add a Vitest reporter and a Playwright reporter under scripts/test-progress/ that share one writer. Each writes .test-progress/<runner>.json with { runner, state, unit, done, total, failed, startedAt, updatedAt }. Write to a temp file and rename it. Swallow every error so a reporter can never fail a run. Vitest counts files, Playwright counts tests, and a retried test counts once.
  • Add both reporters to the existing reporter lists in the configs (don't replace what's there), and gitignore .test-progress/.
  • The mod polls .test-progress/ every second, shows a band above the prompt with a progress bar, done/total, percent, elapsed time, ETA and failed count, plus a Hide button, and a short status-line entry while a run is live. Finished runs linger 30 seconds; a running file silent for 10 minutes is a dead run.
  • Then update the config comments, the test instructions and CLAUDE.md so a CLI --reporter flag never silently drops the progress reporter.

Answer Enable for this session when it asks about hot reloading. Then make it permanent yourself (see step 4 below), because Claude is correctly not allowed to.

The Manual Way ​

1. The shared writer, scripts/test-progress/write.mjs:

js
// Shared writer for the test-progress reporters (`vitest.mjs`, `playwright.mjs`).
//
// Each runner owns one small JSON file under `.test-progress/` (gitignored). A Claude
// Code mod polls that directory to show how far a test run has gone and an ETA. The
// file format below is its contract:
//
//   { runner, state: 'running'|'done'|'failed', unit: 'files'|'tests',
//     done, total, failed, startedAt, updatedAt }   (timestamps are ISO strings)
//
// Written to a temp name then renamed, so the poller never reads half a file. A reporter
// must never be able to fail a test run, so every write swallows its own errors.

import { mkdirSync, renameSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'

const DIR = '.test-progress'

/**
 * Create a writer bound to one runner.
 * @param {string} runner file stem and display name, e.g. `vitest`
 * @param {'files'|'tests'} unit what `done`/`total` count
 * @returns {{ start: (total: number) => void, tick: (didFail: boolean) => void, end: (state: 'done'|'failed') => void }}
 */
export function createProgress(runner, unit) {
  let total = 0
  let done = 0
  let failed = 0
  let startedAt = ''

  const flush = (state) => {
    try {
      mkdirSync(DIR, { recursive: true })
      const file = join(DIR, `${runner}.json`)
      const body = { runner, state, unit, done, total, failed, startedAt, updatedAt: new Date().toISOString() }
      writeFileSync(`${file}.tmp`, JSON.stringify(body))
      renameSync(`${file}.tmp`, file)
    } catch {
      // progress is best-effort; never break the run it describes
    }
  }

  return {
    start(count) {
      total = count
      done = 0
      failed = 0
      startedAt = new Date().toISOString()
      flush('running')
    },
    tick(didFail) {
      done += 1
      if (didFail) failed += 1
      flush('running')
    },
    end(state) {
      flush(state)
    },
  }
}

2. The two reporters. scripts/test-progress/vitest.mjs:

js
// Vitest reporter: one tick per finished test *file* (the total is known up front from the
// run's specifications, which is what makes an ETA possible).

import { createProgress } from './write.mjs'

export default class TestProgressReporter {
  #progress = createProgress('vitest', 'files')

  onTestRunStart(specifications) {
    this.#progress.start(specifications.length)
  }

  onTestModuleEnd(testModule) {
    this.#progress.tick(testModule.state() === 'failed')
  }

  onTestRunEnd(testModules) {
    this.#progress.end(testModules.some((m) => m.state() === 'failed') ? 'failed' : 'done')
  }
}

scripts/test-progress/playwright.mjs:

js
// Playwright reporter: one tick per finished *test* (a retried test counts once, when its
// last attempt ends). The total comes from the suite Playwright hands `onBegin`, so it
// already reflects `--project`, `--grep` and sharding.

import { createProgress } from './write.mjs'

export default class TestProgressReporter {
  #progress = createProgress('playwright', 'tests')

  onBegin(_config, suite) {
    this.#progress.start(suite.allTests().length)
  }

  onTestEnd(test, result) {
    const isFinal = result.status === 'passed' || result.status === 'skipped' || result.retry >= test.retries
    if (!isFinal) return
    this.#progress.tick(result.status !== 'passed' && result.status !== 'skipped')
  }

  onEnd(result) {
    this.#progress.end(result.status === 'passed' ? 'done' : 'failed')
  }
}

3. Wire them in. Add each reporter to the list that's already there. vitest.config.js:

js
// The second reporter writes .test-progress/vitest.json for the progress/ETA mod.
// A CLI --reporter flag REPLACES this list, dropping the progress reporter. For the full
// human view keep it: --reporter=default --reporter=./scripts/test-progress/vitest.mjs
reporter: ['agent', './scripts/test-progress/vitest.mjs'],

playwright.config.js:

js
// A CLI --reporter flag REPLACES this whole list, silently dropping the progress reporter
// below. To change the console view, keep it:
//   --reporter=list,./scripts/test-progress/playwright.mjs
reporter: [
  ['list'], // or whatever you already use
  // Writes .test-progress/playwright.json for the progress/ETA mod.
  ['./scripts/test-progress/playwright.mjs'],
],

And .gitignore:

# Live test progress for the Claude Code progress/ETA mod (see scripts/test-progress/)
.test-progress/

Run your tests once and check cat .test-progress/*.json. The project half is done when that file shows up and updates. Note that the mod reads .test-progress/ relative to the folder Claude Code was started in, so the reporters and Claude need to share a working directory.

4. The mod. Four files under ~/.claude/mods/test-progress/.

.claude-plugin/plugin.json:

json
{
  "name": "test-progress",
  "version": "0.1.1",
  "description": "Live band and status line showing how far a vitest/Playwright run has gone, with an ETA (reads .test-progress/).",
  "types": "./types/index.d.ts"
}

hooks/hooks.json:

json
{ "modules": ["./register.tsx"] }

types/index.d.ts declares the mod's state so claude plugin validate can check every key the module uses:

ts
export type Row = {
  runner: string
  state: 'running' | 'done' | 'failed'
  unit: 'files' | 'tests'
  done: number
  total: number
  failed: number
  /** Seconds since the run started, as of the last poll. */
  elapsed: number
  /** Seconds left at the run's average pace so far; null before the first unit finishes. */
  eta: number | null
}

declare module 'claude-code' {
  interface PluginState {
    'test-progress': { rows: Row[]; isHidden: boolean }
  }
}

hooks/register.tsx is the mod itself:

tsx
import type { Register } from 'claude-code'

import type { Row } from '../types'

// The contract with scripts/test-progress/write.mjs in the project: one JSON file per
// runner under DIR ({ runner, state, unit, done, total, failed, startedAt, updatedAt }).
const DIR = '.test-progress'
const POLL_MS = 1000
const DONE_LINGER_S = 30 // a finished run stays visible this long
const STALE_S = 600 // a "running" file silent this long is a killed run, not progress
const BAR_WIDTH = 20

const ROWS = { plugin: 'test-progress', key: 'rows' } as const
const HIDDEN = { plugin: 'test-progress', key: 'isHidden' } as const

/** `■■■■■□□□□□…` filled in proportion to done/total. */
export const bar = (done: number, total: number): string => {
  const filled = total > 0 ? Math.min(BAR_WIDTH, Math.round((done / total) * BAR_WIDTH)) : 0
  return '■'.repeat(filled) + '□'.repeat(BAR_WIDTH - filled)
}

/** `m:ss`, or `h:mm:ss` past an hour. */
export const clock = (seconds: number): string => {
  const s = Math.max(0, Math.round(seconds))
  const h = Math.floor(s / 3600)
  const m = Math.floor((s % 3600) / 60)
  const ss = String(s % 60).padStart(2, '0')
  return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
}

/** Seconds remaining at the average pace so far; null until a unit has finished. */
export const eta = (done: number, total: number, elapsed: number): number | null =>
  done > 0 && total >= done ? (elapsed * (total - done)) / done : null

export const register: Register = on => {
  on('session.start', async ($, e, next) => {
    const poll = async () => {
      const now = await $.clock.now()
      let names: string[] = []
      try {
        names = (await $.fs.list(DIR)).map(f => f.name).filter(n => n.endsWith('.json'))
      } catch {
        // no directory yet: nothing has run
      }

      const found: Row[] = []
      for (const name of names) {
        try {
          const data = JSON.parse(await $.fs.read(`${DIR}/${name}`))
          const idle = (now - Date.parse(data.updatedAt)) / 1000
          const isOver = data.state === 'running' ? idle >= STALE_S : idle >= DONE_LINGER_S
          if (isOver) continue
          // A finished run's clock stops at its last write; a live one runs to `now`.
          const end = data.state === 'running' ? now : Date.parse(data.updatedAt)
          const elapsed = Math.max(0, (end - Date.parse(data.startedAt)) / 1000)
          found.push({
            runner: data.runner,
            state: data.state,
            unit: data.unit,
            done: data.done,
            total: data.total,
            failed: data.failed,
            elapsed: Math.round(elapsed),
            eta: data.state === 'running' ? eta(data.done, data.total, elapsed) : null,
          })
        } catch {
          // half-written or malformed file: pick it up on the next poll
        }
      }
      found.sort((a, b) => a.runner.localeCompare(b.runner))

      // Write only on change, so an idle band doesn't redraw every second.
      const held = await $.state.get(ROWS)
      if (JSON.stringify(found) !== JSON.stringify(held.value ?? [])) {
        await $.state.set(ROWS, found)
      }

      const live = found.filter(r => r.state === 'running')
      $.ui.status(
        live.length === 0
          ? undefined
          : live
              .map(r => `${r.runner} ${r.done}/${r.total}${r.eta === null ? '' : ` ~${clock(r.eta)}`}`)
              .join(' · '),
      )
    }

    await poll()
    $.clock.every(POLL_MS, poll)

    return next(e)
  })

  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
    const rows = (await $.state.get(ROWS)).value ?? []
    const isHidden = (await $.state.get(HIDDEN)).value ?? false
    if (e.props.hasSurvey || rows.length === 0 || isHidden) {
      return next(e)
    }

    const { Box, Button, Text } = $.ui.resolve(e)

    return (
      <Box flexDirection="column">
        {rows.map(r => {
          const pct = r.total > 0 ? Math.round((r.done / r.total) * 100) : 0
          const glyph = r.state === 'running' ? '▶' : r.state === 'failed' ? '✗' : '✓'
          const tail =
            r.state === 'running'
              ? `${clock(r.elapsed)} elapsed · ETA ${r.eta === null ? '…' : clock(r.eta)}`
              : `finished in ${clock(r.elapsed)}`
          return (
            <Text key={r.runner} dimColor={r.state === 'done'}>
              {glyph} {r.runner} {bar(r.done, r.total)} {r.done}/{r.total} {r.unit} · {pct}% ·{' '}
              {tail}
              {r.failed > 0 ? ` · ${r.failed} failed` : ''}
            </Text>
          )
        })}
        <Box>
          <Button key="hide" label="Hide" onPress={() => $.state.set(HIDDEN, true)} />
        </Box>
      </Box>
    )
  })
}

Notice that $.fs, $.clock and $.state come from the mod runtime rather than Node's fs and timers. That's what lets the mod's own tests run against a fake clock and filesystem. The three pure helpers are exported so they can be tested directly, in hooks/register.test.ts:

ts
import { expect, test } from 'claude-code/testing'

import { bar, clock, eta } from './register'

test('bar fills in proportion', () => {
  expect(bar(0, 10)).toBe('□'.repeat(20))
  expect(bar(5, 10)).toBe('■'.repeat(10) + '□'.repeat(10))
  expect(bar(10, 10)).toBe('■'.repeat(20))
  expect(bar(1, 0)).toBe('□'.repeat(20))
})

test('clock formats m:ss and h:mm:ss', () => {
  expect(clock(0)).toBe('0:00')
  expect(clock(75)).toBe('1:15')
  expect(clock(3725)).toBe('1:02:05')
})

test('eta is null before the first unit, then average pace', () => {
  expect(eta(0, 10, 5)).toBeNull()
  expect(eta(2, 10, 20)).toBe(80)
  expect(eta(10, 10, 20)).toBe(0)
})

5. Load it in every session. Add the folder to ~/.claude/settings.json:

json
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/.claude/mods/test-progress" }

Restart Claude Code. The setting is read when a session starts, so sessions that are already open won't pick it up. claude plugin validate ~/.claude/mods/test-progress checks the manifest and the state contract.

6. Keep Claude from turning it off. This is the step I learned the hard way. Add a line like this to your CLAUDE.md:

markdown
- **Test runs always load the progress reporters** (`scripts/test-progress/`, which feed the
  test-progress mod). Never pass a bare `--reporter=…` to `vitest` or `playwright test`. It
  replaces the configured list and the mod goes dark. Use no flag, or append the progress
  reporter: `--reporter=line,./scripts/test-progress/playwright.mjs` for Playwright,
  `--reporter=verbose --reporter=./scripts/test-progress/vitest.mjs` for Vitest.

If you have a /test command or similar instructions, put the same exact flags there.

The Takeaway ​

The useful idea here isn't the progress bar. It's the shape: let the tool that knows the facts write a small file, and let the display poll it. The reporters don't know the mod exists, and the mod doesn't know how the tests were started. That's why it worked for runs Claude started, runs a background agent started, and runs I started by hand, and it's why the same reporters could later feed a git hook without changing the mod.

The other lesson is about instructions. The code took Claude two minutes. Keeping it working took a rule in CLAUDE.md, because an agent that wants quieter test output will reach for --reporter, and nothing breaks loudly when it does. The band just quietly stops appearing.


The reporters are in the WarpCad repo, along with the later push-gate version.

— John Webb Cole

Built with Claude Code, reviewed by me.