---
name: cw-test
description: Run an end-to-end CW (morse) decode round-trip between two virtual wfweb rigs. Boots the testrig bench, drives both browsers via Playwright MCP, has each station key a known string with the keyer while the other decodes it with the built-in CW decoder, verifies the copy, screenshots both, and tears down. Use when the user asks to test CW, run a CW QSO, verify the CW decoder, or validate the keyer TX / decode RX path.
---

Run an end-to-end **CW decode round-trip** between two virtual rigs: each station keys a known string (server-side keyer → virtualrig morse synthesis) and the other must decode it with the built-in CW decoder. Drive both browsers via Playwright MCP, screenshot both sides, tear down. Bench-only (virtual rigs, no hardware, no real RF).

This skill is a sibling of `packet-test` / `ft8-test` and reuses the same bench (`scripts/testrig.sh`) and Playwright lifecycle. Read "Pitfalls" before debugging — CW shares FT8's RX-audio gate **and** needs the DECODE toggle on.

## Prerequisites (verify before starting; fix and stop if missing)

1. `./wfweb` and `./tools/virtualrig/virtualrig` are built. If not: `qmake wfweb.pro && make -j$(nproc)` and `cd tools/virtualrig && qmake && make -j$(nproc)`. (virtualrig must include CW synthesis — `synthesizeCw` in `tools/virtualrig/src/virtualrig.cpp`; any recent build has it.)
2. Repo `.mcp.json` includes `--ignore-https-errors` for the playwright MCP. Verify: `pgrep -af @playwright/mcp` must show `--ignore-https-errors`. If `.mcp.json` was just edited, the user must restart Claude Code (a `/mcp` reconnect re-uses cached args). Stop and tell them.
3. Load the playwright tools via ToolSearch: `select:mcp__playwright__browser_navigate,mcp__playwright__browser_snapshot,mcp__playwright__browser_click,mcp__playwright__browser_type,mcp__playwright__browser_take_screenshot,mcp__playwright__browser_wait_for,mcp__playwright__browser_evaluate,mcp__playwright__browser_close,mcp__playwright__browser_tabs,mcp__playwright__browser_select_option`.

## Boot the bench

```
./scripts/testrig.sh up 2
```

Both rigs come up on **14.074.000**. The skill puts both into **CW** mode on the same frequency, so audio gates (matching freq+mode) and A's keyed morse reaches B as RX audio.

- A → https://127.0.0.1:9080  (virtual-IC7300-A)
- B → https://127.0.0.1:9090  (virtual-IC7300-B)

## Per-tab UI bring-up (do for both A and B)

CW is a **rig mode** (unlike FT8's bar toggle): you switch the rig into CW, then open the CW bar.

1. `browser_navigate` to the rig URL.
2. **Wipe stale browser state**: `browser_evaluate` `() => { localStorage.clear(); sessionStorage.clear(); }`, then `browser_navigate` to the same URL again.
3. Click the **CLICK TO START** splash (gesture needed for `AudioContext`).
4. **Wait for RX audio.** The CW decoder taps the RX-audio playback chain, so audio must be flowing. The splash enables it ~1 s *asynchronously* — poll until `window.audioEnabled === true` before continuing.
5. **Switch to CW mode:** `window.setMode('CW')`. This is **async** (round-trips to the rig) — poll until `window.currentMode === 'CW'` (a few hundred ms).
6. **Open the CW bar:** `window.renderCWOverlay()` (equivalently the bottom-bar **KEY** button, which calls `toggleCWBar()`). Confirm `#cwBar` no longer has class `hidden`.
7. **Turn the decoder on:** click `#cwDecoderToggle` (the **DECODE** button). It gains class `active` and `textContent` `DECODE`. Do this on **both** tabs (each side decodes the other). Give it ~1 s to spin up its worklet.

When refs go stale across snapshots, drive directly via `browser_evaluate` with stable IDs (the normal path, not a fallback):
- Buttons: `cwDecoderToggle` (DECODE on/off), `cwStopBtn`, `cwCloseBtn`, `cwSpeedUp`, `cwSpeedDown`, `cwLogBtn`
- Fields: `cwInput` (type-to-send), `cwSpeedInput`, `cwCallInput`, `cwQsoInput`
- TX transcript (what THIS station is sending): `#cwDisplay` (and the `cwCharacters` global)
- **RX decode output (what this station COPIED): `#cwDecoderTextInner` `.textContent`** ← assert on this
- Functions (global): `setMode('CW')`, `renderCWOverlay()`, `toggleCWBar()`, `sendCWText(text)`
- State (globals on `window`): `currentMode`, `cwSpeed` (default 20 wpm), `audioEnabled`

## QSO sequence (single bidirectional run)

1. **A → key a string**: on tab A, `window.sendCWText('CQ TEST DE W1AAA')`. This queues `{cmd:'sendCW'}`; virtualrig synthesizes the morse at the rig's CW pitch and routes it to B. At 20 wpm this 16-char string plays in ~8–10 s.
2. **Read B's copy**: on tab B, poll `document.getElementById('cwDecoderTextInner').textContent` until it contains the sent text (allow ~30 s; give a few extra seconds for trailing characters). On the clean virtual channel at 20 wpm the copy is exact (`CQ TEST DE W1AAA`), but **assert tolerantly** — require the callsign `W1AAA` (and ideally `TEST`/`CQ`) to appear, not a byte-exact match, since a real decoder can drop or mangle a character.
3. **B → key the reply**: on tab B, `window.sendCWText('W1AAA DE W1BBB GE')`.
4. **Read A's copy**: on tab A, poll `#cwDecoderTextInner` until it contains `W1BBB`.
5. **Screenshot both** with `browser_take_screenshot`, `fullPage: true`. Save as `.playwright-mcp/wfweb-cw-A.png` and `...-B.png`. (Bare names land in the project root; `/tmp` is rejected — the MCP roots are the project dir and `.playwright-mcp/`.)

## Teardown and report

1. `browser_close` (both tabs), then `./scripts/testrig.sh down`.
2. Report PASS/FAIL and the two screenshot paths. PASS = each side copied the other's transmission (the expected callsign appears in its `#cwDecoderTextInner`).

## Pitfalls — read before debugging

- **Two gates, not one.** The decoder needs (a) `window.audioEnabled === true` (RX audio flowing — enabled ~1 s after CLICK TO START) **and** (b) the **DECODE** toggle (`#cwDecoderToggle`) clicked `active`. Miss either and `#cwDecoderTextInner` stays empty.
- **`setMode('CW')` is async.** Poll `window.currentMode === 'CW'` before keying — keying while still in USB means no CW is generated and the receiver isn't gated to hear it.
- **Both rigs must be in CW on the same freq.** Audio gates on matching freq+mode (same as packet). If one side is still USB, the mixer won't route the morse to it.
- **Don't confuse TX transcript with RX decode.** `#cwDisplay` / `cwCharacters` is the *sender's* outgoing text (what it's keying). The *received* copy is `#cwDecoderTextInner` on the **other** tab. Assert on the receiver's `#cwDecoderTextInner`.
- **Assert tolerantly.** The CW decoder is heuristic. On the clean virtual channel at 20 wpm it's a perfect copy, but write the assert as "contains the callsign", not byte-equality, so a single mis-decode doesn't fail a genuinely-working run. If you want maximum robustness, slow down (`cwSpeed` / `#cwSpeedDown`) before keying.
- **State is window globals**, not a namespaced `window.X.state` object.
- **Don't skip `localStorage.clear()`** — clean-slate mirrors a fresh user and avoids stale CW speed/mode.
- **Stale Playwright `ref=` handles** re-render constantly; drive via `browser_evaluate` + the stable IDs above.
- **Screenshots are sandboxed** to the project dir / `.playwright-mcp/`; `/tmp` is rejected.
- **`cwLogBtn` (LOG QSO) writes a scratch logbook, not the station log.** `testrig.sh` starts each instance with `--logbook .testrig/logbook_<i>.adi --no-remote-log`; a wfweb started by hand without `--logbook` opens the operator's real logbook — never log a bench QSO against one.
- **Never key a physically-connected rig** — this skill is virtual-bench only (no real RF).
