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)
./wfweband./tools/virtualrig/virtualrigare built. If not:qmake wfweb.pro && make -j$(nproc)andcd tools/virtualrig && qmake && make -j$(nproc). (virtualrig must include CW synthesis —synthesizeCwintools/virtualrig/src/virtualrig.cpp; any recent build has it.)- Repo
.mcp.jsonincludes--ignore-https-errorsfor the playwright MCP. Verify:pgrep -af @playwright/mcpmust show--ignore-https-errors. If.mcp.jsonwas just edited, the user must restart Claude Code (a/mcpreconnect re-uses cached args). Stop and tell them. - 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.
browser_navigateto the rig URL.- Wipe stale browser state:
browser_evaluate() => { localStorage.clear(); sessionStorage.clear(); }, thenbrowser_navigateto the same URL again. - Click the CLICK TO START splash (gesture needed for
AudioContext). - 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 === truebefore continuing. - Switch to CW mode:
window.setMode('CW'). This is async (round-trips to the rig) — poll untilwindow.currentMode === 'CW'(a few hundred ms). - Open the CW bar:
window.renderCWOverlay()(equivalently the bottom-bar KEY button, which callstoggleCWBar()). Confirm#cwBarno longer has classhidden. - Turn the decoder on: click
#cwDecoderToggle(the DECODE button). It gains classactiveandtextContentDECODE. 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 thecwCharactersglobal) - 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)
- 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. - Read B's copy: on tab B, poll
document.getElementById('cwDecoderTextInner').textContentuntil 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 callsignW1AAA(and ideallyTEST/CQ) to appear, not a byte-exact match, since a real decoder can drop or mangle a character. - B → key the reply: on tab B,
window.sendCWText('W1AAA DE W1BBB GE'). - Read A's copy: on tab A, poll
#cwDecoderTextInneruntil it containsW1BBB. - Screenshot both with
browser_take_screenshot,fullPage: true. Save as.playwright-mcp/wfweb-cw-A.pngand...-B.png. (Bare names land in the project root;/tmpis rejected — the MCP roots are the project dir and.playwright-mcp/.)
Teardown and report
browser_close(both tabs), then./scripts/testrig.sh down.- 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) clickedactive. Miss either and#cwDecoderTextInnerstays empty. setMode('CW')is async. Pollwindow.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/cwCharactersis the sender's outgoing text (what it's keying). The received copy is#cwDecoderTextInneron 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.stateobject. - 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 viabrowser_evaluate+ the stable IDs above. - Screenshots are sandboxed to the project dir /
.playwright-mcp/;/tmpis rejected. - Never key a physically-connected rig — this skill is virtual-bench only (no real RF).