Skip to content
Merged
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
250 changes: 139 additions & 111 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,202 +2,230 @@

WebSocket relay for [TWD](https://github.com/nicolo-ribaudo/twd-js) — lets AI agents and external tools trigger and observe in-browser test runs.

Your app runs tests in the browser with twd-js. twd-relay adds a relay server and a browser client so that a **client** (script, CI, or AI agent) can send a run command over WebSocket; the relay forwards it to the browser, and test events are streamed back. No Vite or specific framework required: the relay can run standalone and work with any app that loads the browser client.
Your app runs tests in the browser with twd-js. twd-relay adds a relay server and a browser client so a **client** (script, CI, or AI agent) can send a `run` command over WebSocket; the relay forwards it to the browser, and test events stream back.

---

## Architecture

- **Relay server** — WebSocket server that accepts one browser connection and many client connections. Clients send commands (`run`, `status`); the relay forwards them to the browser. The browser runs tests and streams events back; the relay broadcasts those to all clients. A lock prevents concurrent runs.
## Quick start (Vite)

- **Browser client** (`twd-relay/browser`) — Runs in your app. Connects to the relay, listens for commands, uses `twd-js/runner` to execute tests, and streams results back. Logs connection state in the console (e.g. `[twd-relay] Connected to relay`).
The Vite plugin attaches the relay to the dev server **and** auto-injects the browser client into your `index.html` — one line in `vite.config.ts` and you're done.

- **Vite plugin** (`twd-relay/vite`) — Optional. Attaches the relay to your Vite dev server so the WebSocket is on the same origin. Also available: a **standalone CLI** that runs the relay on its own HTTP server (default port 9876).
**1. Install:**

### Protocol (summary)
```bash
npm install --save-dev twd-relay
```

1. Browser connects → sends `{ type: 'hello', role: 'browser' }`
2. Client connects → sends `{ type: 'hello', role: 'client' }`
3. Client sends `{ type: 'run', scope: 'all' }` (optionally with `testNames` to filter) → relay forwards to browser
4. Browser runs tests and streams events → relay broadcasts to clients
5. Browser sends `{ type: 'heartbeat' }` every 3s during a run (relay consumes these, never forwarded to clients)
6. `run:complete` clears the run lock (and the send-run script exits)
Peer dependency: **twd-js** (>=1.4.0). Your app must use twd-js for tests; the browser client imports `twd-js/runner` at runtime.

### Heartbeat & frozen-tab recovery
**2. Add the plugin:**

During an active test run the browser sends a heartbeat every 3 seconds. The relay tracks the last heartbeat time and checks every 10 seconds. If no heartbeat arrives for **120 seconds** during an active run, the relay considers the run dead (browser tab frozen by the OS), resets the run lock, and broadcasts to all clients:
```ts
// vite.config.ts
import { twdRemote } from 'twd-relay/vite';

```json
{ "type": "run:abandoned", "reason": "heartbeat_timeout" }
export default defineConfig({
plugins: [react(), twdRemote()],
});
```

The CLI prints a clear message — `Run abandoned — browser tab appears frozen. Refresh the browser tab and retry.` — and exits with code 1. This is especially useful for AI agent workflows, where the agent gets an actionable signal instead of a silent 180s timeout followed by a cryptic `RUN_IN_PROGRESS` error.

---
That's the whole setup. The plugin only runs in dev (`apply: 'serve'`); production builds are untouched.

## Installation
**3. Run your app, then trigger a test run:**

```bash
npm install twd-relay
npm run dev # in one terminal
npx twd-relay run # in another — connects, runs, exits 0/1
```

Peer dependency: **twd-js** (>=1.4.0). Your app must use twd-js for tests; the browser client imports `twd-js/runner` at runtime.
`twd-relay run` defaults to port 5173 (Vite). See [CLI run command](#cli-run-command) for filters and flags.

---
### Visual feedback in the browser tab

## Quick start (standalone relay)
When connected, the browser client sets a colored favicon and prefixes `document.title` so you can spot the active TWD tab among many:

Works with any framework. Run the relay on one port and your app on another.
| Favicon | Title prefix | State |
|---|---|---|
| Blue | `[TWD]` | Connected, idle |
| Orange | `[TWD ...]` | Tests running |
| Green | `[TWD ✓]` | Last run passed |
| Red | `[TWD ✗]` | Last run had failures |

**1. Start the relay** (from this repo, or use the CLI in your project):
On disconnect or eviction (another tab taking over), the original favicon and title are restored.

```bash
npm run relay
# or: npx twd-relay
# Listens on ws://localhost:9876/__twd/ws (use --port to change)
```
---

**2. In your app**, connect the browser client and call `connect()`:
## Plugin options

```js
import { createBrowserClient } from 'twd-relay/browser';
```ts
twdRemote({
path: '/__twd/ws', // WebSocket path (relative to Vite `base`)
autoConnect: true, // inject the browser client into index.html
});

const client = createBrowserClient({
url: 'ws://localhost:9876/__twd/ws',
twdRemote({ autoConnect: false }); // opt out — wire createBrowserClient manually

twdRemote({ // forward client options into the injected call
autoConnect: { reconnect: false, log: true, maxTestDurationMs: 5000 },
});
client.connect();
```

Once connected, the browser client sets a colored favicon and prefixes `document.title` so you can spot the active TWD tab at a glance:
| Option | Type | Default | Description |
|---|---|---|---|
| `path` | `string` | `'/__twd/ws'` (relative to Vite `base`) | WebSocket path. Used by the relay and the injected client — single source of truth. |
| `autoConnect` | `boolean \| AutoConnectOptions` | `true` | Inject the browser client connect script into `index.html`. `false` opts out. Object form forwards `reconnect`, `reconnectInterval`, `log`, `maxTestDurationMs` into the injected `createBrowserClient` call. |

| Favicon | Title prefix | State |
|---|---|---|
| Blue | `[TWD]` | Connected, idle |
| Orange | `[TWD ...]` | Tests running |
| Green | `[TWD ✓]` | Last run passed |
| Red | `[TWD ✗]` | Last run had failures |
---

On disconnect or eviction (another tab taking over), the original favicon and title are restored.
## Operational reliability

These features are on by default — no configuration needed.

### Aborting throttled runs

Chrome aggressively throttles timers in backgrounded tabs, which can stretch a 1-second test run to 30+ seconds. To avoid AI/CI hangs, the browser client monitors per-test wall-clock time. If any single test runs longer than 10 seconds (configurable), the browser emits `run:aborted`, the CLI prints a clear error with recovery guidance, and the run ends with exit code 1.
Chrome aggressively throttles timers in backgrounded tabs, which can stretch a 1-second test run to 30+ seconds. To avoid AI/CI hangs, the browser client monitors per-test wall-clock time. If any single test runs longer than 10 seconds, the browser emits `run:aborted`, the CLI prints a clear error, and the run exits with code 1.

Override the threshold with `--max-test-duration <ms>` on `twd-relay run`, or pass `maxTestDurationMs` to `createBrowserClient`. Set it to `0` to disable detection entirely:
Override the threshold with `--max-test-duration <ms>` on `twd-relay run`, or pass `maxTestDurationMs` to `twdRemote({ autoConnect: { ... } })`. Set it to `0` to disable detection:

```bash
twd-relay run --max-test-duration 20000 # raise to 20s for heavy multistep tests
twd-relay run --max-test-duration 0 # disable detection
```

The default of 10 s is chosen to sit above the Testing Library default `findBy*` timeout (3 s). A legitimately failing test with one or two missed selectors still completes under the threshold, while throttled runs — where tests typically cluster in the 10–30 s range trip the abort reliably.
The default of 10 s sits above the Testing Library default `findBy*` timeout (3 s). A legitimately failing test still completes under the threshold; throttled runs cluster in the 10–30 s range and trip the abort reliably.

Recovery when an abort fires: foreground the TWD tab (identified by the `[TWD …]` title prefix set by the favicon indicator) and retry. For unattended runs (CI, agents), prefer `twd-cli`: it drives a headless browser where the tab is always focused and throttling doesn't apply.
Recovery: foreground the TWD tab (identified by the `[TWD …]` title prefix) and retry. For unattended runs, prefer `twd-cli`it drives a headless browser where the tab is always focused.

**3. Open your app in a browser** — the page connects to the relay as “browser”.
### Frozen-tab recovery (heartbeat)

**4. Trigger a run** — something must connect as a **client** and send `run`:
During a run, the browser sends a heartbeat every 3 seconds. The relay tracks the last heartbeat and checks every 10 seconds. If no heartbeat arrives for **120 seconds** during an active run, the relay considers the run dead (browser tab frozen by the OS), resets the run lock, and broadcasts:

- From this repo: `npm run send-run` (or `node scripts/send-run.js [--port 9876]`). The script exits when it receives `run:complete`.
- From another project (if `ws` is available, e.g. via twd-relay): use the one-liner below.
```json
{ "type": "run:abandoned", "reason": "heartbeat_timeout" }
```

### One-liner to trigger a run
The CLI prints `Run abandoned — browser tab appears frozen. Refresh the browser tab and retry.` and exits 1. AI agents get an actionable signal instead of a 180 s timeout followed by a cryptic `RUN_IN_PROGRESS` error.

Run from a directory where `ws` is installed (e.g. project with twd-relay):
### Failures recap

```bash
node -e 'const Ws=require("ws");const w=new Ws("ws://localhost:9876/__twd/ws");let s=false;w.on("open",()=>w.send(JSON.stringify({type:"hello",role:"client"})));w.on("message",d=>{const m=JSON.parse(d);console.log(m.type,m);if(m.type==="connected"&&m.browser&&!s){s=true;w.send(JSON.stringify({type:"run",scope:"all"}));}if(m.type==="run:complete"){w.close();}});w.on("close",()=>process.exit(0));'
```

Change the URL if your relay uses another port or path.
When tests fail, the CLI prints a recap block at the very end of the output listing each failed test and its error. This survives `tail -N` truncation and is easy to copy as a single block.

---

## Vite plugin (optional)
## Manual setup (non-Vite, or opting out)

If you use Vite, the plugin attaches the relay to the dev server **and** auto-injects the browser client into your `index.html` — so you don't need to call `createBrowserClient` yourself:
Use this path for **Webpack, Angular CLI, Rollup, esbuild, Rspack** — anywhere the Vite plugin doesn't apply — or when you want full control over the browser client lifecycle in a Vite project (set `autoConnect: false` on the plugin).

```ts
// vite.config.ts
import { twdRemote } from 'twd-relay/vite';
**1. Run a relay** — either standalone, or attached to a dev server you control:

export default defineConfig({
plugins: [react(), twdRemote()],
});
```bash
npx twd-relay
# Listens on ws://localhost:9876/__twd/ws (use --port to change)
```

That's the whole setup. The plugin only runs in dev (`apply: 'serve'`); production builds are untouched.

### Plugin options

| Option | Type | Default | Description |
|---|---|---|---|
| `path` | `string` | `'/__twd/ws'` (relative to Vite `base`) | WebSocket path. Used both by the relay and by the injected client — single source of truth. |
| `autoConnect` | `boolean \| AutoConnectOptions` | `true` | Inject the browser client connect script into `index.html`. Set to `false` to wire `createBrowserClient` manually in your entry file. Pass an object to forward options (`reconnect`, `reconnectInterval`, `log`, `maxTestDurationMs`) into the injected `createBrowserClient` call. |

### Manual usage (non-Vite, or opting out)
**2. Connect the browser client in your app entry file:**

`twd-relay/browser` is the supported public API for any setup the Vite plugin doesn't cover (Webpack, Angular, Rollup, esbuild, Rspack), and for advanced Vite users who want to subscribe to client events or coordinate connect timing. See the [Quick start](#quick-start-standalone-relay) section above for the manual snippet. To use the manual snippet **with** the Vite plugin, set `autoConnect: false` so the plugin doesn't also inject one — otherwise two clients connect.
```js
import { createBrowserClient } from 'twd-relay/browser';

---
const client = createBrowserClient({
url: 'ws://localhost:9876/__twd/ws',
});
client.connect();
```

## Scripts (this repo)
`createBrowserClient` accepts `url`, `path`, `reconnect`, `reconnectInterval`, `log`, and `maxTestDurationMs`. See [`src/browser/types.ts`](./src/browser/types.ts) for the full interface.

| Script | Description |
|---------------|-------------|
| `npm run build` | Build relay, browser, vite entry points + CLI |
| `npm run relay` | Build and start the standalone relay (port 9876) |
| `npm run send-run`| Connect as client and send `run`; exits on `run:complete` |
| `npm run dev` | Start relay only (assumes already built) |
| `npm run test` | Run tests (watch) |
| `npm run test:ci` | Run tests with coverage |
**3. Open the app in a browser, then trigger a run:**

---
```bash
npx twd-relay run --port 9876
```

## Exports
> ⚠️ **Don't enable both auto-connect and a manual `createBrowserClient` call.** Two clients will connect — visible in relay logs as a duplicate browser. Either remove the manual block, or set `autoConnect: false` on `twdRemote()`.

| Export | Use |
|--------|-----|
| `twd-relay` (main) | Relay server: `createTwdRelay(httpServer, options)` |
| `twd-relay/browser` | Browser client: `createBrowserClient(options)` |
| `twd-relay/vite` | Vite plugin: `twdRemote(options)` |
### One-liner to trigger a run from a script

CLI: `twd-relay` (or `npx twd-relay`) — two subcommands:
When you don't want the CLI but already have `ws` available:

- `twd-relay serve` (default) — start the standalone relay
- `twd-relay run` — connect to a relay and trigger a test run
```bash
node -e 'const Ws=require("ws");const w=new Ws("ws://localhost:9876/__twd/ws");let s=false;w.on("open",()=>w.send(JSON.stringify({type:"hello",role:"client"})));w.on("message",d=>{const m=JSON.parse(d);console.log(m.type,m);if(m.type==="connected"&&m.browser&&!s){s=true;w.send(JSON.stringify({type:"run",scope:"all"}));}if(m.type==="run:complete"){w.close();}});w.on("close",()=>process.exit(0));'
```

---

## CLI `run` command

Connect to an existing relay, trigger tests, stream output, and exit with 0 (all pass) or 1 (failures).
Connect to a running relay, trigger tests, stream output, exit 0 (all pass) or 1 (failures).

```bash
# Run all tests (connects to Vite dev server on port 5173 by default)
# Run all tests (defaults to port 5173 — Vite dev server)
twd-relay run

# Run on a different port
# Different port (e.g. standalone relay)
twd-relay run --port 9876

# Run specific tests by name (substring match, case-insensitive)
# Filter tests by name (substring match, case-insensitive, repeatable)
twd-relay run --test "should show error"

# Multiple filters — runs tests matching any of them
twd-relay run --test "login" --test "signup"
```

| Flag | Description | Default |
|------|-------------|---------|
| `--port <port>` | Relay port | 5173 |
| `--host <host>` | Relay host | localhost |
|---|---|---|
| `--port <port>` | Relay port | `5173` |
| `--host <host>` | Relay host | `localhost` |
| `--path <path>` | WebSocket path | `/__twd/ws` |
| `--timeout <ms>` | Timeout | 180000 |
| `--timeout <ms>` | Run timeout | `180000` |
| `--max-test-duration <ms>` | Per-test wall-clock abort threshold | `10000` |
| `--test <name>` | Filter tests by name substring (repeatable) | — |

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

When any tests fail, the CLI prints a recap block at the very end of the output listing each failed test and its error. This survives `tail -N` truncation and is easy to copy as a single block.
---

## Architecture

- **Relay server** (`twd-relay`) — WebSocket server that accepts one browser connection and many client connections. Clients send commands (`run`, `status`); the relay forwards them. The browser runs tests and streams events back; the relay broadcasts to all clients. A lock prevents concurrent runs.
- **Browser client** (`twd-relay/browser`) — Runs in your app. Connects to the relay, listens for commands, uses `twd-js/runner` to execute tests, and streams results back. Logs connection state to the console.
- **Vite plugin** (`twd-relay/vite`) — Attaches the relay to your Vite dev server **and** auto-injects the browser client. The recommended path for Vite projects.
- **Standalone CLI** (`twd-relay` bin) — Runs the relay on its own HTTP server (default port 9876) for projects that aren't on Vite.

### Protocol summary

1. Browser connects → sends `{ type: 'hello', role: 'browser' }`
2. Client connects → sends `{ type: 'hello', role: 'client' }`
3. Client sends `{ type: 'run', scope: 'all' }` (optionally `testNames: string[]`) → relay forwards to browser
4. Browser runs tests and streams events → relay broadcasts to clients
5. Browser sends `{ type: 'heartbeat' }` every 3 s during a run (consumed by the relay, not forwarded)
6. `run:complete` clears the run lock

---

## Exports

| Export | Use |
|---|---|
| `twd-relay` (main) | Relay server: `createTwdRelay(httpServer, options)` |
| `twd-relay/browser` | Browser client: `createBrowserClient(options)` |
| `twd-relay/vite` | Vite plugin: `twdRemote(options)` |

CLI: `twd-relay` (or `npx twd-relay`):

- `twd-relay serve` (default) — start the standalone relay
- `twd-relay run` — connect to a relay and trigger a test run

---

## Scripts (this repo)

| Script | Description |
|---|---|
| `npm run build` | Build relay, browser, vite entry points + CLI |
| `npm run relay` | Build and start the standalone relay (port 9876) |
| `npm run send-run` | Connect as client and send `run`; exits on `run:complete` |
| `npm run dev` | Start relay only (assumes already built) |
| `npm run test` | Run tests (watch) |
| `npm run test:ci` | Run tests with coverage |

---

Expand Down
Loading