Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,11 @@ twd-relay run
# Different port (e.g. standalone relay)
twd-relay run --port 9876

# Filter tests by name (substring match, case-insensitive, repeatable)
# Filter tests by name or describe block (substring match against the full
# path "Describe > nested > test name", case-insensitive, repeatable)
twd-relay run --test "should show error"
twd-relay run --test "Login flow" # runs every test in that describe
twd-relay run --test "login flow > shows error" # path match across describe/test
twd-relay run --test "login" --test "signup"
```

Expand All @@ -177,9 +180,9 @@ twd-relay run --test "login" --test "signup"
| `--path <path>` | WebSocket path | `/__twd/ws` |
| `--timeout <ms>` | Run timeout | `180000` |
| `--max-test-duration <ms>` | Per-test wall-clock abort threshold | `10000` |
| `--test <name>` | Filter tests by name substring (repeatable) | — |
| `--test <name>` | Filter tests by substring of the full describe-path (repeatable) | — |

When `--test` is used and no tests match, the CLI prints the available test names so you can correct the filter.
When `--test` is used and no tests match, the CLI prints the available tests as full describe-paths so you can correct the filter.

---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Describe-Aware `--test` Filtering — Design

**Date:** 2026-07-04
**Status:** Approved

## Problem

`--test "name"` (and the `testNames` field of the `run` command) only substring-matches
`it(...)` test names. Filtering by a `describe(...)` name silently matches nothing.
This creates friction for developers and especially for AI agents, since most models
assume describe-name matching is the default behavior (as in Vitest/Jest). twd-cli
already implements describe-aware matching; twd-relay should match its semantics.

## Approach (chosen)

Full-path matching, identical to twd-cli: for each test, build the path
`"Describe > nested describe > test name"` by walking `parent` links in
`window.__TWD_STATE__.handlers`, then substring-match each lowercased filter against
the lowercased path. A filter equal to a describe name selects all tests under it;
cross-boundary filters like `"login > error"` also work.

Rejected alternative: matching test name OR any ancestor name individually — slightly
stricter, but diverges from twd-cli and blocks cross-boundary filters.

## Scope

Only the browser client's matching logic changes. Relay server, message protocol, and
CLI flag parsing are untouched — `testNames: string[]` already flows through as-is.

## Changes

### 1. New helper `buildTestPath` (`src/browser/buildTestPath.ts`)

Port of twd-cli's `buildTestPath`, adapted to the `Map<string, Handler>` the browser
client already reads from `__TWD_STATE__`. Walks `handler.parent` up through suites,
joins names with `" > "`, returns `null` if the starting id is missing. Pure function.

### 2. Matching in `handleRunCommand` (`src/browser/createBrowserClient.ts`)

Replace the current name-only loop (~lines 149–177): for each handler with
`type === 'test'`, build its full path and match each filter as a lowercased substring
of the lowercased path.

### 3. NO_MATCH message lists full paths

The existing `NO_MATCH` error lists available tests by bare name; change it to list
full paths (`"Login flow > shows error"`), so an agent can construct a working retry.

### 4. Docs

Update README's `--test` description (relay and `run` subcommand sections): matches
against the full describe-path, with an example showing filtering by describe name.

## Testing

- Unit tests for `buildTestPath`: root-level test, nested describes, missing parent id.
- Browser-client tests via the existing pattern (unique port, `TrackedWs` wrapper):
- filter by describe name selects all tests under it
- filter by test name still works
- `run` message with `testNames` containing a `" > "` cross-boundary filter matches
- no-match case emits `NO_MATCH` listing full paths

## Not included (YAGNI)

twd-cli's per-filter "matched nothing (others did)" warning — the relay protocol has
no warning channel and the all-or-nothing `NO_MATCH` covers the agent-facing failure
mode. Revisit if friction shows up.
25 changes: 25 additions & 0 deletions src/browser/buildTestPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export interface PathHandler {
id: string;
name: string;
parent?: string;
type: 'suite' | 'test';
}

/**
* Builds the full describe-path for a test, e.g.
* "Login flow > validation > shows error on bad password".
* Returns null if the starting id is not in the map.
*/
export function buildTestPath(
testId: string,
handlers: Map<string, PathHandler>,
): string | null {
let current = handlers.get(testId);
if (!current) return null;
const parts: string[] = [];
while (current) {
parts.unshift(current.name);
current = current.parent ? handlers.get(current.parent) : undefined;
}
return parts.join(' > ');
}
16 changes: 3 additions & 13 deletions src/browser/createBrowserClient.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { BrowserClient, BrowserClientOptions } from './types';
import { createFaviconManager } from './faviconManager';
import { createRunMonitor } from './runMonitor';
import { selectTestIds, listTestPaths } from './filterTests';

declare global {
interface Window {
Expand Down Expand Up @@ -147,21 +148,10 @@ export function createBrowserClient(options?: BrowserClientOptions): BrowserClie
let testIds: string[] | undefined;

if (testNames && testNames.length > 0) {
const lowerNames = testNames.map(n => n.toLowerCase());
const matched: string[] = [];
for (const [, handler] of handlers) {
if (handler.type === 'test') {
const lowerName = handler.name.toLowerCase();
if (lowerNames.some(n => lowerName.includes(n))) {
matched.push(handler.id);
}
}
}
const matched = selectTestIds(handlers, testNames);

if (matched.length === 0) {
const available = Array.from(handlers.values())
.filter(h => h.type === 'test')
.map(h => h.name);
const available = listTestPaths(handlers);
send({ type: 'run:start', testCount: 0 });
send({
type: 'error',
Expand Down
36 changes: 36 additions & 0 deletions src/browser/filterTests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { buildTestPath, type PathHandler } from './buildTestPath';

/**
* Returns ids of tests whose full describe-path contains any filter
* as a case-insensitive substring. Mirrors twd-cli's selectTestIds.
*/
export function selectTestIds(
handlers: Map<string, PathHandler>,
filters: string[],
): string[] {
const needles = filters.map((f) => f.toLowerCase());
const ids: string[] = [];

for (const [, handler] of handlers) {
if (handler.type !== 'test') continue;
const path = buildTestPath(handler.id, handlers);
if (!path) continue;
const haystack = path.toLowerCase();
if (needles.some((n) => haystack.includes(n))) {
ids.push(handler.id);
}
}

return ids;
}

/** Full describe-paths of all tests — used in the NO_MATCH error message. */
export function listTestPaths(handlers: Map<string, PathHandler>): string[] {
const paths: string[] = [];
for (const [, handler] of handlers) {
if (handler.type !== 'test') continue;
const path = buildTestPath(handler.id, handlers);
if (path) paths.push(path);
}
return paths;
}
40 changes: 40 additions & 0 deletions src/tests/browser/buildTestPath.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { buildTestPath, type PathHandler } from '../../browser/buildTestPath';

function makeHandlers(list: PathHandler[]): Map<string, PathHandler> {
return new Map(list.map((h) => [h.id, h]));
}

describe('buildTestPath', () => {
it('returns the bare name for a root-level test', () => {
const handlers = makeHandlers([
{ id: 't1', name: 'adds numbers', type: 'test' },
]);
expect(buildTestPath('t1', handlers)).toBe('adds numbers');
});

it('joins nested describe names with " > "', () => {
const handlers = makeHandlers([
{ id: 's1', name: 'Login flow', type: 'suite' },
{ id: 's2', name: 'validation', type: 'suite', parent: 's1' },
{ id: 't1', name: 'shows error on bad password', type: 'test', parent: 's2' },
]);
expect(buildTestPath('t1', handlers)).toBe(
'Login flow > validation > shows error on bad password'
);
});

it('returns null for an unknown id', () => {
const handlers = makeHandlers([
{ id: 't1', name: 'adds numbers', type: 'test' },
]);
expect(buildTestPath('missing', handlers)).toBeNull();
});

it('stops walking when a parent id is missing from the map', () => {
const handlers = makeHandlers([
{ id: 't1', name: 'orphan test', type: 'test', parent: 'gone' },
]);
expect(buildTestPath('t1', handlers)).toBe('orphan test');
});
});
60 changes: 60 additions & 0 deletions src/tests/browser/filterTests.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { selectTestIds, listTestPaths } from '../../browser/filterTests';
import type { PathHandler } from '../../browser/buildTestPath';

function makeHandlers(list: PathHandler[]): Map<string, PathHandler> {
return new Map(list.map((h) => [h.id, h]));
}

const handlers = makeHandlers([
{ id: 's1', name: 'Login flow', type: 'suite' },
{ id: 't1', name: 'shows error on bad password', type: 'test', parent: 's1' },
{ id: 't2', name: 'redirects on success', type: 'test', parent: 's1' },
{ id: 's2', name: 'Signup', type: 'suite' },
{ id: 't3', name: 'shows error on taken email', type: 'test', parent: 's2' },
{ id: 't4', name: 'root-level smoke test', type: 'test' },
]);

describe('selectTestIds', () => {
it('matches by test name substring (existing behavior)', () => {
expect(selectTestIds(handlers, ['redirects'])).toEqual(['t2']);
});

it('matches all tests under a describe by its name', () => {
expect(selectTestIds(handlers, ['login flow'])).toEqual(['t1', 't2']);
});

it('is case-insensitive', () => {
expect(selectTestIds(handlers, ['LOGIN FLOW'])).toEqual(['t1', 't2']);
});

it('supports cross-boundary filters spanning describe and test', () => {
expect(selectTestIds(handlers, ['login flow > shows error'])).toEqual(['t1']);
});

it('unions multiple filters without duplicating ids', () => {
expect(selectTestIds(handlers, ['login flow', 'shows error'])).toEqual([
't1', 't2', 't3',
]);
});

it('returns an empty array when nothing matches', () => {
expect(selectTestIds(handlers, ['nonexistent'])).toEqual([]);
});

it('never returns suite ids', () => {
const ids = selectTestIds(handlers, ['login flow']);
expect(ids).not.toContain('s1');
});
});

describe('listTestPaths', () => {
it('lists full paths of all tests, skipping suites', () => {
expect(listTestPaths(handlers)).toEqual([
'Login flow > shows error on bad password',
'Login flow > redirects on success',
'Signup > shows error on taken email',
'root-level smoke test',
]);
});
});
Loading