Imported from lidofinance/csm-widget (
.claude/skills/visual-verify/SKILL.md). Install upstream withnpx skills add lidofinance/csm-widget --skill visual-verify. Copyright stays with the author.
Visual verification of the running app
This app is a Web3 dApp: every meaningful page requires a connected wallet. A vanilla automated
browser has no window.ethereum, so the connect modal has nothing to click and you only ever see
the welcome screen. This skill solves that with csm-dev-wallet — a dev wallet extension that
injects a provider and lets you connect as any address or node operator without keys or signing
(the app runs on Hoodi testnet, where watch-only is enough to render every connected page).
Prerequisites
- A target app must be reachable — the local dev server at
http://localhost:3000(yarn dev), the CM instance (e.g.localhost:3001), or a remote testnet (--url https://csm.testnet.fi). This skill does not start anything — check first (curl -sI <url>), and ask the user to runyarn devif local is down. - Which module (CSM vs CM) and chain the target runs on are detected automatically from the app itself, so the same command works against any instance — no need to know or pass them.
csm-dev-walletis auto-installed into.claude/skills/visual-verify/.cache/on first run (it's a QA tool, deliberately kept out of the project'spackage.json).playwrightis reused from the repo.
Quickstart — screenshot any page in any state
# Dashboard, connected as node operator #4 (resolves the operator's manager address automatically)
node .claude/skills/visual-verify/scripts/verify.mjs --operator 4 --route /
# Settings, as operator #4's rewards address
node .claude/skills/visual-verify/scripts/verify.mjs --operator 4 --role rewards --route /settings
# Any route, connected as a raw address
node .claude/skills/visual-verify/scripts/verify.mjs --address 0xd8dA…96045 --route /keys
# A different instance — the CM-module app, or a remote testnet. Module + chain auto-detected from the URL.
node .claude/skills/visual-verify/scripts/verify.mjs --url http://localhost:3001 --operator 2
node .claude/skills/visual-verify/scripts/verify.mjs --url https://cm.testnet.fi --operator 5
# Dark mode, and a mobile-width layout (combine freely)
node .claude/skills/visual-verify/scripts/verify.mjs --operator 4 --theme dark
node .claude/skills/visual-verify/scripts/verify.mjs --operator 4 --viewport mobile --theme dark
# Just one element (cropped, scrolled into view) instead of the whole page
node .claude/skills/visual-verify/scripts/verify.mjs --operator 4 --selector '[data-testid="accountSectionHeader"]'
# The disconnected/welcome state — no wallet at all (operator/address not required)
node .claude/skills/visual-verify/scripts/verify.mjs --no-connect --route /
# The mandatory "Select Node Operator" modal itself (clears the cached pick so it reappears)
node .claude/skills/visual-verify/scripts/verify.mjs --address 0x028913…e8de --select-prompt
# Watch it happen live, and leave the browser open to poke around
node .claude/skills/visual-verify/scripts/verify.mjs --operator 12 --headed --keep-open
It prints the resulting STATE (url / connected / account / operator), confirms the requested operator
is the one actually shown, a page-health line (console errors, uncaught exceptions, failed + 4xx/5xx
requests), and a screenshot path — then Read the PNG to actually look at it. The health line is what
catches a page that looks fine but is actually broken (e.g. a dev server serving 404 chunks). If a run
fails at any step, it still saves a FAILED-*.png of wherever it got stuck (and reports its path) — a
failure image usually shows the cause (a modal that never opened, a blank card, the wrong route).
--operator N guarantees operator N is displayed. Many operators share an address (e.g. one address
is the rewards address of operators 4, 36 and 318). Connecting only pins the address; the app then shows
a default operator (the first or last-cached), which may be a sibling, not N. So after connecting the
script switches the in-app operator selection to N and verifies it — if N isn't available for the resolved
address, it errors and lists what is available. (--address has no specific operator, so no switch.)
Default output is .cache/shots/<module>-<tag>.png (prefixed with module + any theme/viewport variant
so they don't collide); override with --out path.png (relative paths resolve to cwd). Flags:
--url <origin> (default $APP_ORIGIN / localhost:3000) | --operator N | --address 0x… |
--role manager|rewards|owner (default manager) | --route /path | --theme light|dark |
--viewport mobile|tablet|desktop|WxH | --selector <css> (shoot one element) |
--no-connect (capture with no wallet; operator/address not required) |
--select-prompt (capture the operator-selection modal instead of resolving it) | --json |
--module csm|cm | --chain 560048 | --headed | --keep-open | --full (full-page) |
--out file.png. --module and --chain are auto-detected from the target URL — only pass them to override.
--json emits one structured object on stdout (progress logs go to stderr) with the connection
details, state, the screenshot path, and health + full diagnostics (the console/network errors).
Use it when an agent needs to act on the result — health.total > 0 is the machine-readable "this page
has problems" signal even when the screenshot looks fine. On failure it prints { "ok": false, "error" }
and exits non-zero.
--theme works via prefers-color-scheme (the app honours it), set before first paint and verified by
page-background luminance, with the header theme toggler as fallback — so it's reliable even at mobile
widths where the toggler hides in a menu. --viewport presets: mobile 390×844, tablet 768×1024,
desktop 1280×800 (or pass an explicit WIDTHxHEIGHT).
Finding operators
To pick an operator — or find an address that controls several (needed to exercise the in-app operator switcher, which changes operator without a wallet change):
node .claude/skills/visual-verify/scripts/operators.mjs # summary + multi-operator addresses
node .claude/skills/visual-verify/scripts/operators.mjs --id 4 # full details for operator #4
How it connects (the non-obvious bits)
These quirks are already handled in scripts/lib.mjs; understand them before writing custom flows:
- Connection is seeded, not clicked (with a modal fallback). Setting
eth_accountsalone does NOT auto-connect — wagmi/reef-knot only adopt a connection from persisted storage or an explicit connector call. SoconnectAswrites the exact 4localStoragekeys a real connect leaves behind (terms-accepted flag +wagmi.recentConnectorId/reconnect-wallet-id+ thewagmi.storeconnection record) viaaddInitScriptbefore load, and the app reconnects the injected wallet on boot — no clicks. If that reconnect doesn't take (e.g. wagmi bumpswagmi.store's schema or thereef-knot_accept-terms_n2version suffix drifts), it falls back to the proven modal flow:connectBtn→ tick the Terms checkbox (it gates the wallet buttons) → click the "Browser" option (the injected provider shows up under the pinnedbrowserExtensionconnector, not by the wallet's own name). - The header chip re-renders asynchronously after any navigation, reload, or account change (wagmi
reconnects, balances fetch). Always wait for the header to settle into connected-or-disconnected
before reading state or screenshotting, or you capture a blank chip.
settle()does this. - A multi-operator wallet must pick an operator before the app renders. When the connected address
manages 2+ operators and no cached pick matches one of them, the app opens a mandatory selection modal
and shows only a splash behind it — no account chip, no operator header, no connect button.
connectAsresolves it automatically (landing on--operator Nwhen you gave one, else the first row), and navigation readiness accepts the modal as "app rendered" for exactly this reason. Pass--select-promptto capture the modal instead of resolving it. - Wallet account vs active operator are two layers. One address can manage many operators; the app's
switcher (
data-testid="nodeOperatorHeader") changes the active operator with no wallet event. The in-app operator selection is connection-scoped — it resets to the default operator on reconnect/reload, which is whyconnectAsre-asserts it (viaensureOperator) after the final navigation, not before.
Writing custom multi-step flows
For scenarios beyond a single screenshot (persistence checks, account switching, the operator switcher, disconnect/reconnect), import the library instead of shelling out to the CLI:
import { connectAs, ensureOperator, readState, settle, HOODI } from './lib.mjs';
const { context, wallet, page } = await connectAs({
operatorId: 318,
route: '/settings',
});
await wallet.switchAccount(page, '0xba99…26af'); // change wallet account → app reacts live
await settle(page);
await ensureOperator(page, 36); // switch in-app operator (no wallet change)
console.log(await readState(page)); // { url, connected, account, operator }
await page.screenshot({ path: 'after-switch.png' });
await context.close();
connectAs(...) returns Playwright's context/page plus the dev-wallet wallet controller, and (for
operatorId) guarantees that operator is active. Helpers: ensureOperator(page, id) (in-app switch +
verify), resolveOperatorSelection(page, id?) (pick in the selection modal; no-op when it isn't open),
currentOperatorId(page), readState, settle, connectModal. Key wallet methods:
switchAccount(page, addr), switchNetwork(page, chainId), disconnect(page),
selectOperator(page, id, role), refreshOperators(page, chainId, module), getOperator(page, id, …).
Full controller API, the underlying wallet_test* RPC primitives (for drivers that can't use the
helper), and a dev-browser fallback (for headed/persistent sessions) are in
references/advanced.md.
Key app facts
- Network: Hoodi testnet,
chainId 560048(auto-detected per instance). The app's RPC proxy (<origin>/api/rpc) is reused to resolve operators. - CSM vs CM: the app serves one module per instance (
MODULEenv). They have different operator sets, so an id valid in CSM may not exist in CM.operators.mjs --url <cm-origin>lists the right set. The connect flow, header testids, and switcher are shared across both modules. - Header testids:
accountSectionHeader(wallet chip),nodeOperatorHeader(active operator + switcher),connectBtn(shown only when disconnected). Account modal:disconnectBtn,connectedAddress. Operator switcher rows:switchModalOperatorRow, with a "Switch" button per non-current row. - Selection modal rows:
selectModalOperatorRow(the whole row is the click target — no per-row button),selectModalOperatorName. The pick is cached inlocalStorageundersm-<moduleId>-no-<address>; clearing thesm--prefixed keys makes the modal appear again. - Routes are normal Next.js paths:
/,/keys,/settings(redirects to a sub-tab like/settings/roles), etc. Use whatever the app's nav exposes. MODULE(csm/cm) is set by the server env; pass--moduleto match if you've switched it.
Troubleshooting
- Welcome screen / never connects → app likely on a different
MODULEor chain than you passed, or the connect modal changed. Re-run with--headedto watch, and check the testids above still exist. ECONNREFUSED/ blank page → dev server isn't running; startyarn dev.- Slow first load → the dev server cold-compiles on first hit; navigation auto-retries (commit +
app-shell wait, 3 attempts). If it still reports "never became ready", the server is stuck — restart
yarn dev. - Operator resolve fails →
/api/rpcproxy unreachable or the id doesn't exist on this chain; tryoperators.mjsto list valid ids. - "never became ready" on a multi-operator wallet → the selection modal was open, and the app hides
its shell while it is. Expected only on an outdated
lib.mjs: readiness must acceptselectModalOperatorRow. Pass--select-promptif the modal is what you wanted to capture. - Chromium missing → run
yarn playwright install chromium(the repo's documented setup step).