Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d9e715b
docs: design for shardable run artifacts and a merge command
kevinccbsg Aug 19, 2026
b2c119d
docs: drop --report from the sharding design, record maxFailures rati…
kevinccbsg Aug 19, 2026
26b5412
docs: scope behavior changes to sharded runs only, add beta release plan
kevinccbsg Aug 19, 2026
46231e3
docs: add per-shard failed count to the report schema
kevinccbsg Aug 19, 2026
44d0182
docs: implementation plan for shardable run artifacts
kevinccbsg Aug 19, 2026
976c372
docs(plan): share readValue between both arg parsers
kevinccbsg Aug 19, 2026
10d35cc
feat(shard): parse shard specs and slice test ids round-robin
kevinccbsg Aug 19, 2026
995fa8a
feat(cli): add --shard, --report-dir, and merge arg parsing
kevinccbsg Aug 19, 2026
40ae283
feat(report): build run reports and fingerprint discovered tests
kevinccbsg Aug 19, 2026
b4a7f2c
feat(report): read and write shard report artifacts
kevinccbsg Aug 19, 2026
3266a29
feat(coverage): merge per-shard Istanbul coverage maps
kevinccbsg Aug 19, 2026
2ac151e
feat(merge): associative report merge with consistency validation
kevinccbsg Aug 19, 2026
96d9dac
fix(merge): pin first-wins semantics with a test, correct handlers co…
kevinccbsg Aug 19, 2026
8f0ef78
feat(summary): add per-shard breakdown and wall vs compute duration
kevinccbsg Aug 19, 2026
182ea01
feat(run): slice tests by shard and write a run report artifact
kevinccbsg Aug 19, 2026
84f68fe
feat(cli): add the merge command and wire shard flags through bin
kevinccbsg Aug 19, 2026
e80fbfd
ci: verify sharded runs and merge end to end
kevinccbsg Aug 19, 2026
e0304d6
ci: fix unfalsifiable assertions in the merge verify step
kevinccbsg Aug 19, 2026
29e5d1e
chore(release): 1.5.0-beta.0
kevinccbsg Aug 20, 2026
58a6096
fix(shard): key cross-shard identity on test paths and positions, not…
kevinccbsg Aug 20, 2026
ce29f51
docs: sync the spec's schema block to v2, and pin the version literal
kevinccbsg Aug 20, 2026
7936a96
perf(cli): stop merge from loading puppeteer
kevinccbsg Aug 20, 2026
84fe76e
feat(action): add a shard input to the composite action
kevinccbsg Aug 20, 2026
f74cdd7
docs: move sharding into docs/ and put contract validation first
kevinccbsg Aug 20, 2026
e508207
docs: mark sharding as beta and give it a runnable action example
kevinccbsg Aug 25, 2026
a5b7570
chore(release): 1.5.0
kevinccbsg Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions .github/actions/run/action.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: 'TWD CLI Run'
description: 'Run TWD browser tests with Puppeteer, including optional contract validation reporting'
description: 'Run TWD browser tests with Puppeteer, optionally as one shard of a parallel matrix, including contract validation reporting'
branding:
icon: 'check-circle'
color: 'green'
Expand All @@ -13,6 +13,25 @@ inputs:
description: 'Post contract validation report as a PR comment (requires pull-requests: write permission)'
required: false
default: 'false'
shard:
description: >-
Run one shard of the suite, as <index>/<total> (e.g. 2/4). Each shard
discovers the whole suite and takes every nth test, so the test count
never has to be known in advance. Leave empty to run everything in one
job. Join the shards afterwards with `npx twd-cli merge`.
required: false
default: ''
report-dir:
description: 'Where the shard report is written. Only used when `shard` is set.'
required: false
default: '.twd/run'
upload-report:
description: >-
Upload the shard report as an artifact named `twd-run-<index>`, which is
the layout `twd-cli merge` expects after actions/download-artifact. Only
used when `shard` is set.
required: false
default: 'true'

runs:
using: 'composite'
Expand All @@ -29,10 +48,48 @@ runs:
shell: bash
run: npx puppeteer browsers install chrome

- name: Resolve shard
id: shard
shell: bash
env:
SHARD: ${{ inputs.shard }}
run: |
if [ -z "$SHARD" ]; then
echo "index=" >> "$GITHUB_OUTPUT"
exit 0
fi
# Artifact names cannot contain "/", so the index alone identifies the
# shard. twd-cli itself validates the spec and fails loudly on a bad
# one, so this only has to be good enough to name the artifact.
if ! printf '%s' "$SHARD" | grep -qE '^[0-9]+/[0-9]+$'; then
echo "::error::Invalid shard \"$SHARD\". Expected <index>/<total>, e.g. 2/4."
exit 1
fi
echo "index=${SHARD%%/*}" >> "$GITHUB_OUTPUT"

- name: Run TWD tests
shell: bash
working-directory: ${{ inputs.working-directory }}
run: npx twd-cli run
env:
SHARD: ${{ inputs.shard }}
REPORT_DIR: ${{ inputs.report-dir }}
run: |
if [ -n "$SHARD" ]; then
npx twd-cli run --shard "$SHARD" --report-dir "$REPORT_DIR"
else
npx twd-cli run
fi

- name: Upload shard report
# always(): a red shard must still upload, or `merge` cannot tell "this
# shard failed" from "this shard never ran", and it reports the gap as a
# missing artifact instead of the real failure.
if: always() && inputs.shard != '' && inputs.upload-report == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: twd-run-${{ steps.shard.outputs.index }}
path: ${{ inputs.working-directory }}/${{ inputs.report-dir }}
if-no-files-found: error

- name: Post contract report to PR
if: inputs.contract-report == 'true' && github.event_name == 'pull_request'
Expand All @@ -42,7 +99,16 @@ runs:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
SHARD: ${{ inputs.shard }}
run: |
# A sharded run deliberately writes no contract markdown: every shard
# would overwrite the others with a fraction of the mocks. `twd-cli
# merge` writes it, so the comment belongs in the merge job.
if [ -n "$SHARD" ]; then
echo "::notice::Skipping the contract PR comment: this is shard $SHARD. Post it from the job that runs \`twd-cli merge\`, which is what writes the report."
exit 0
fi

REPORT_PATH=$(node -e "
const fs = require('fs');
try {
Expand Down
121 changes: 121 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,124 @@ jobs:
echo "" >> "$REPORT_PATH"
echo "[View full report →](${RUN_URL})" >> "$REPORT_PATH"
gh pr comment "${PR_NUMBER}" --body-file "$REPORT_PATH"

e2e-sharded:
runs-on: ubuntu-latest

strategy:
# Without this, the first red shard cancels its siblings and the merge job
# sees gaps it cannot distinguish from a crashed shard.
fail-fast: false
matrix:
# The "2" here must match the shard total below (--shard N/2). Bump both
# together, or shards will disagree on the total and merge will reject them.
shard: [1, 2]

steps:
- name: Checkout repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5

- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 24
cache: npm

- name: Install CLI dependencies
run: npm ci

- name: Install test-example-app dependencies
working-directory: test-example-app
run: npm install

- name: Install mock service worker
working-directory: test-example-app
run: npx twd-js init public --save

- name: Cache Puppeteer browsers
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ~/.cache/puppeteer
key: ${{ runner.os }}-puppeteer-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-puppeteer-

- name: Install Chrome for Puppeteer
run: npx puppeteer browsers install chrome

- name: Start dev server
working-directory: test-example-app
run: |
nohup npx vite --host > vite.log 2>&1 &
npx wait-on http://localhost:5173 --timeout 30000

- name: Run TWD tests for this shard
working-directory: test-example-app
run: node ../bin/twd-cli.js run --shard ${{ matrix.shard }}/2

- name: Upload shard report
# Always: a red shard must still upload, or merge cannot tell "this shard
# failed" from "this shard never ran".
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: twd-run-${{ matrix.shard }}
path: test-example-app/.twd/run
if-no-files-found: error

e2e-merge:
runs-on: ubuntu-latest
needs: [e2e-sharded]
# Runs even though a shard job may have exited 1. Without this the merged
# summary — the point of the exercise — is never printed.
if: ${{ !cancelled() }}

steps:
- name: Checkout repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5

- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 24
cache: npm

- name: Install CLI dependencies
run: npm ci

- name: Download shard reports
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: twd-run-*
path: test-example-app/.twd/shards

- name: Merge shard reports
working-directory: test-example-app
run: node ../bin/twd-cli.js merge .twd/shards

- name: Verify the merged report
working-directory: test-example-app
run: |
# Cheap sanity guard, not a real gate: the "Merge shard reports" step
# above has no `if: always()`, so we only reach here after `twd-cli
# merge` exited 0 — and runMerge only writes merged-run.json once it
# has decided to succeed (src/mergeCommand.js). This check protects
# against that assumption changing later, but it cannot fail today.
if [ ! -f .twd/merged-run.json ]; then
echo "ERROR: merged report not generated"
exit 1
fi
node -e "
const r = require('./.twd/merged-run.json');
if (r.tests.length !== r.discovery.totalTests) {
console.error('ERROR: ' + r.tests.length + ' merged tests but ' +
r.discovery.totalTests + ' discovered');
process.exit(1);
}
const executed = r.shards.reduce((sum, s) => sum + s.executed, 0);
if (executed !== r.tests.length) {
console.error('ERROR: shards report ' + executed + ' executed but merged ' + r.tests.length + ' tests');
process.exit(1);
}
console.log('Merged ' + r.tests.length + ' tests from ' + r.shards.length + ' shards');
"
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
## <small>1.5.0 (2026-08-25)</small>

* feat(shard): `--shard <i>/<n>` runs one slice of the suite so a run can be split across parallel CI jobs. Each shard discovers the whole suite itself and takes every nth test, so the test count never has to be known in advance
* feat(shard): a sharded run writes `run.json` and `coverage.json` to `./.twd/run` (`--report-dir` to change it) — the first machine-readable output twd-cli has had
* feat(merge): `npx twd-cli merge <dir>` joins shard reports into one report covering test results, coverage and contract validation, prints a single summary with a per-shard breakdown, and owns the exit code
* feat(merge): a missing shard report is an error naming the gap, not a silently incomplete report. Shards also fingerprint the test list they discovered, so shards that saw different test sets refuse to merge
* chore(packaging): a `files` allowlist in package.json — the published package is now just `bin/`, `src/`, `README.md`, `CHANGELOG.md` and `LICENSE`. `tests/`, `test-example-app/`, `docs/` and the repo tooling were all being published and no longer are, taking the tarball from ~209 kB to ~33 kB (99 files to 25, ~850 kB to ~101 kB unpacked). Nothing that was importable before has moved
* note: **sharding ships as a beta feature.** It is strictly additive, so a run without `--shard` is unaffected, but which tests land in which shard is not yet a stable contract — a later release is likely to group by top-level `describe` so a suite always stays in one shard
* note: no behavior change without `--shard`. A plain run writes the same files, prints the same output, and exits the same way as 1.4.0

Sharding needs three things right in the workflow: `fail-fast: false` on the
matrix, `if: always()` on the shard's artifact upload, and
`if: ${{ !cancelled() }}` on the merge job. Each one breaks the run differently
if left out. See [docs/sharding.md](docs/sharding.md) for a runnable workflow.

A normal release: `npm install twd-cli` gets it. The *sharding feature* is the
part marked beta — everything else in this version is stable.

## <small>1.4.0 (2026-07-28)</small>

* feat(record): video recording for twd-cli runs (#13) ([94c21e0](https://github.com/BRIKEV/twd-cli/commit/94c21e0)), closes [#13](https://github.com/BRIKEV/twd-cli/issues/13)
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ CI/CD runner for [TWD (Test while developing)](https://brikev.github.io/twd/)
- [Recording](#recording): capture a run to video, paced so it is watchable
- [Contract Validation](#contract-validation): check your mocks against OpenAPI specs
- [CI/CD Integration](#cicd-integration): GitHub Action and custom setups
- [Sharding across CI jobs](#sharding-across-ci-jobs) **(beta)**: split a long run across parallel jobs ([details](docs/sharding.md))
- [How It Works](#how-it-works)
- [Requirements](#requirements)

Expand Down Expand Up @@ -349,6 +350,34 @@ When `contractReportPath` is set and you use the action with `contract-report: '

Failed validations are included in a collapsible details section with a link to the full CI log.

## Sharding across CI jobs

> **Beta.** Strictly additive: a run without `--shard` behaves exactly as before,
> so turning this on cannot affect your existing pipeline. How tests are assigned
> to shards may still change — see [docs/sharding.md](docs/sharding.md).

Long suites can be split across parallel CI jobs. Each shard runs one slice of
the suite and writes a report; `twd-cli merge` joins them into a single summary
and owns the exit code.

```bash
npx twd-cli run --shard 2/4 # "I am job 2 of 4"
npx twd-cli merge .twd/shards # join the reports back together
```

The `4` is how many jobs you are running, **not** how many tests exist — each
shard discovers the whole suite itself and keeps every 4th test, so the suite can
grow without a workflow edit.

**Sharding only pays on long suites.** It trades fixed per-job setup for parallel
execution, so a suite that runs in seconds comes out *slower*. As a rule of
thumb, two shards win once test time is more than twice the merge job's cost.

See **[docs/sharding.md](docs/sharding.md)** for the full workflow, the three
conditions that are easy to get wrong, the break-even maths with measured
numbers, and the caveats — test independence, `maxFailures` being per shard, and
coverage on a red run.

## Requirements

- Node.js >= 20.19.x
Expand Down
42 changes: 38 additions & 4 deletions bin/twd-cli.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,35 @@
#!/usr/bin/env node

import { runTests } from '../src/index.js';
import { parseRunArgs } from '../src/parseArgs.js';
// runTests and runMerge are imported inside their branches, not here. A static
// import of src/index.js pulls in puppeteer, so `twd-cli merge` — which never
// opens a browser — would otherwise load the whole browser-automation graph
// before it even looked at argv.
import { parseRunArgs, parseMergeArgs } from '../src/parseArgs.js';

const command = process.argv[2];

if (command === 'run') {
try {
const { testFilters, record } = parseRunArgs(process.argv.slice(3));
const hasFailures = await runTests({ testFilters, recordOverrides: record });
const { testFilters, record, shard, reportDir } = parseRunArgs(process.argv.slice(3));
const { runTests } = await import('../src/index.js');
const hasFailures = await runTests({
testFilters,
recordOverrides: record,
shard,
reportDir,
});
process.exit(hasFailures ? 1 : 0);
} catch (error) {
if (!error?.reported) {
console.error(error?.message ?? String(error));
}
process.exit(1);
}
} else if (command === 'merge') {
try {
const { dir, out } = parseMergeArgs(process.argv.slice(3));
const { runMerge } = await import('../src/mergeCommand.js');
const hasFailures = runMerge({ dir, out });
process.exit(hasFailures ? 1 : 0);
} catch (error) {
if (!error?.reported) {
Expand All @@ -26,13 +47,26 @@ Usage:
contains <name> (case-insensitive).
Repeatable; multiple --test values are OR'd.
npx twd-cli run --record Record the run to a video file
npx twd-cli run --shard 2/4 (beta) Run only this shard's slice of the
suite and write a report to ./.twd/run
npx twd-cli merge <dir> (beta) Merge shard reports from <dir> into
one report, exit 1 if the run failed

Examples:
npx twd-cli run --test "shows error"
npx twd-cli run --test "Login" --test "Signup"
npx twd-cli run --shard 2/4
npx twd-cli merge .twd/shards

Options:
--test "<name>" Filter tests by "suite > test" path (repeatable, OR'd)
--shard <i>/<n> (beta) Run slice i of n. Each shard discovers the
whole suite and takes every nth test, so the count
never has to be known in advance. Implies a report.
Which tests land in which shard may change.
--report-dir <path> Where to write the shard report (default ./.twd/run)
--out <path> merge only: where to write the merged report
(default ./.twd/merged-run.json)
--record Record the run to a video file (requires ffmpeg)
--record-dir <path> Output directory (default ./twd-artifacts)
--record-speed <n> Playback speed, e.g. 0.5 for half speed
Expand Down
Loading
Loading