Verify UI in the running dev app
yarn start launches Electron with --inspect=9339 (main-process Node
inspector; there is no renderer CDP port). Everything below drives the
app through that socket: real menus, real Redux, real paint.
This skill drives the local macOS dev machine — commands (pkill, /tmp
paths) are macOS-specific by design. No Windows variants.
When to use
- A UI change needs visual proof (component tests can't see paint — a
clipped SVG passes every DOM assertion).
- You need the simulate flows (
Simulate Download / Simulate Update Flow)
run and screenshotted at specific progress points.
- You need computed styles, bounding boxes, or DOM structure from the live
renderer.
Before connecting — the three pitfalls
- Watcher restarts kill everything. The rollup watcher restarts the
whole app when ANY bundle rebuilds (including after a subagent's last
file save). Confirm the
yarn start log shows no bundles src/ /
Restarting main process lines for 12–15s before any timing-sensitive
run. A builder's "finished" report can arrive before its final saves hit
the watcher.
- Occluded windows lie. macOS stops painting occluded windows and
capturePage returns the last painted frame — screenshots freeze while
the DOM moves. Always win.show(); win.focus() before captures.
- Singleton wedges. If the inspector port refuses connections while an
Electron process exists, two instances raced the SingletonLock. Recovery:
pkill -9 -f "<worktree-name>", wait, single fresh yarn start
(cold boot ≈ 30s).
- Background
gitnexus analyze interferes. While it runs it mutates
worktree git state and touches watched files — it can restart the app
mid-verification (phantom bundles src/ rebuilds) and silently drop
freshly staged files from the git index. Don't reindex during a
verification run; when you do reindex, use
node .gitnexus/run.cjs analyze --index-only.
The script
Run with the context-mode sandbox (Bun has a global WebSocket) or any Bun
runtime. Adapt the marked sections.
const targets = await fetch('http://127.0.0.1:9339/json', {
signal: AbortSignal.timeout(3000),
}).then((r) => r.json());
const ws = new WebSocket(targets[0].webSocketDebuggerUrl);
let id = 0;
const pending = new Map();
const REQUEST_TIMEOUT_MS = 5000;
const failAllPending = (reason) => {
for (const [i, { reject }] of pending) {
reject(reason);
pending.delete(i);
}
};
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.id && pending.has(m.id)) {
pending.get(m.id).resolve(m);
pending.delete(m.id);
}
};
ws.onerror = (e) => failAllPending(new Error(`ws error: ${e.message || e}`));
ws.onclose = () => failAllPending(new Error('ws closed'));
const send = (method, params = {}) =>
new Promise((resolve, reject) => {
const i = ++id;
const timer = setTimeout(() => {
pending.delete(i);
reject(
new Error(
`${method} timed out after ${REQUEST_TIMEOUT_MS}ms (watcher restart or dead socket?)`
)
);
}, REQUEST_TIMEOUT_MS);
pending.set(i, {
resolve: (m) => {
clearTimeout(timer);
resolve(m);
},
reject,
});
ws.send(JSON.stringify({ id: i, method, params }));
});
await new Promise((res, rej) => {
ws.onopen = res;
setTimeout(rej, 5000);
});
await send('Runtime.enable');
const ev = async (expression) => {
const r = await send('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
});
if (r.error)
throw new Error(`CDP error: ${JSON.stringify(r.error).slice(0, 400)}`);
if (r.result?.exceptionDetails)
throw new Error(JSON.stringify(r.result.exceptionDetails).slice(0, 400));
return r.result?.result?.value;
};
// `require` is NOT in eval scope — always go through process.mainModule.
const REQ = 'process.mainModule.require';
// Root window = the one BrowserWindow with no parent and not the log-viewer
// window (the only other unparented window `src/main.ts` creates). Reuse
// this exact expression for every operation below — do not re-derive it.
const ROOT_WINDOW = `${REQ}('electron').BrowserWindow.getAllWindows()
.find((w) => !w.isDestroyed() && !w.getParentWindow()
&& w.getTitle() !== 'Log Viewer - Rocket.Chat')`;
// 1. Un-occlude so paint (and capturePage) is live
await ev(`(() => { const w = ${ROOT_WINDOW};
w.show(); w.focus(); return 'ok'; })()`);
// 2. Trigger real flows via menu item ids (works for any getMenuItemById id).
// Assert the gate + item are actually there before clicking — a missing
// or disabled item would otherwise silently no-op and still print 'clicked'.
await ev(`(() => { const { Menu } = ${REQ}('electron');
const menu = Menu.getApplicationMenu();
const devMode = menu?.getMenuItemById('developerMode');
if (!devMode?.checked) throw new Error('developerMode gate is off');
const item = menu.getMenuItemById('simulateDownload');
if (!item) throw new Error('simulateDownload menu item not found');
if (!item.enabled) throw new Error('simulateDownload menu item is disabled');
item.click();
return 'clicked'; })()`);
// 3. Read renderer truth (computed styles > pixels for diagnosis)
console.log(
await ev(`(() => { const w = ${ROOT_WINDOW};
return w.webContents.executeJavaScript(\`(() => {
const b = document.querySelector('button[data-downloads-status]');
return JSON.stringify({ status: b?.getAttribute('data-downloads-status'),
rect: b && b.getBoundingClientRect().toJSON() });
})()\`); })()`)
);
// 4. Screenshot a region (write PNG somewhere readable, then Read it)
await ev(`(() => { const w = ${ROOT_WINDOW};
const [width] = w.getContentSize();
return w.webContents.capturePage(
{ x: Math.max(0, width - 420), y: 0, width: 420, height: 34 }
).then((img) => { ${REQ}('fs').writeFileSync('/tmp/ui_check.png', img.toPNG());
return 'ok'; }); })()`);
ws.close();
Gotchas
- In the main-process inspector sandbox bare
require may be missing — use
process.mainModule.require('electron').
- Developer-menu toggles can be driven with
Menu.getApplicationMenu().getMenuItemById('<id>').click() (ids:
developerMode, simulateUpdate, simulateDownload,
simulateDisconnected).
- The tray
Tray instance is module-scoped and not reachable from CDP;
opening the tray menu needs a real click — osascript/System Events clicks
require Accessibility permission for the terminal app (error -25211
otherwise). screencapture -x + sips -c crops work without permission
for verifying the icon itself.
yarn start relaunches Electron once per bundle for ~60 s; wait for
waiting for changes before screenshots.
Useful recipes
- Real download with known size (slow mirror, good for watching the
ring): grab a cancel handle first, then
wc.downloadURL('https://proof.ovh.net/files/1Gb.dat') on a webview's
webContents (wc.session.once('will-download', (e, item) => { globalThis.__t = item; })),
cancel with globalThis.__t.cancel() when done.
- DOM-truth beats screenshots for diagnosis:
getBoundingClientRect of
svg children vs their svg viewport catches clipping that looks like
"missing artwork"; getComputedStyle(...).stroke/opacity/transition
catches token and animation regressions.
- The dev instance uses the
Rocket.Chat (development) userData profile —
its persisted settings (theme, navigationLayout: 'tabs' | 'sidebar' | 'hidden') live in that profile's config.json; edit + restart to switch
the layout under test (TopBar layouts only render with sidebar/hidden).
1---2name: dev-app-verify3description: Drive and screenshot the running Rocket.Chat Desktop dev app (yarn start) through the main-process inspector on port 9339 — trigger menu items (Simulate Download/Update), evaluate in the renderer DOM, capture titlebar screenshots. Use whenever a UI change needs runtime/visual verification that component tests can't see (paint, clipping, colors, animation, layout).4---56# Verify UI in the running dev app78`yarn start` launches Electron with `--inspect=9339` (main-process Node9inspector; there is **no renderer CDP port**). Everything below drives the10app through that socket: real menus, real Redux, real paint.1112This skill drives the local macOS dev machine — commands (`pkill`, `/tmp`13paths) are macOS-specific by design. No Windows variants.1415## When to use1617- A UI change needs visual proof (component tests can't see paint — a18 clipped SVG passes every DOM assertion).19- You need the simulate flows (`Simulate Download` / `Simulate Update Flow`)20 run and screenshotted at specific progress points.21- You need computed styles, bounding boxes, or DOM structure from the live22 renderer.2324## Before connecting — the three pitfalls25261. **Watcher restarts kill everything.** The rollup watcher restarts the27 whole app when ANY bundle rebuilds (including after a subagent's last28 file save). Confirm the `yarn start` log shows no `bundles src/` /29 `Restarting main process` lines for 12–15s before any timing-sensitive30 run. A builder's "finished" report can arrive before its final saves hit31 the watcher.322. **Occluded windows lie.** macOS stops painting occluded windows and33 `capturePage` returns the last painted frame — screenshots freeze while34 the DOM moves. Always `win.show(); win.focus()` before captures.353. **Singleton wedges.** If the inspector port refuses connections while an36 Electron process exists, two instances raced the SingletonLock. Recovery:37 `pkill -9 -f "<worktree-name>"`, wait, single fresh `yarn start`38 (cold boot ≈ 30s).394. **Background `gitnexus analyze` interferes.** While it runs it mutates40 worktree git state and touches watched files — it can restart the app41 mid-verification (phantom `bundles src/` rebuilds) and silently drop42 freshly staged files from the git index. Don't reindex during a43 verification run; when you do reindex, use44 `node .gitnexus/run.cjs analyze --index-only`.4546## The script4748Run with the context-mode sandbox (Bun has a global `WebSocket`) or any Bun49runtime. Adapt the marked sections.5051```javascript52const targets = await fetch('http://127.0.0.1:9339/json', {53 signal: AbortSignal.timeout(3000),54}).then((r) => r.json());55const ws = new WebSocket(targets[0].webSocketDebuggerUrl);56let id = 0;57const pending = new Map();58const REQUEST_TIMEOUT_MS = 5000;59const failAllPending = (reason) => {60 for (const [i, { reject }] of pending) {61 reject(reason);62 pending.delete(i);63 }64};65ws.onmessage = (e) => {66 const m = JSON.parse(e.data);67 if (m.id && pending.has(m.id)) {68 pending.get(m.id).resolve(m);69 pending.delete(m.id);70 }71};72ws.onerror = (e) => failAllPending(new Error(`ws error: ${e.message || e}`));73ws.onclose = () => failAllPending(new Error('ws closed'));74const send = (method, params = {}) =>75 new Promise((resolve, reject) => {76 const i = ++id;77 const timer = setTimeout(() => {78 pending.delete(i);79 reject(80 new Error(81 `${method} timed out after ${REQUEST_TIMEOUT_MS}ms (watcher restart or dead socket?)`82 )83 );84 }, REQUEST_TIMEOUT_MS);85 pending.set(i, {86 resolve: (m) => {87 clearTimeout(timer);88 resolve(m);89 },90 reject,91 });92 ws.send(JSON.stringify({ id: i, method, params }));93 });94await new Promise((res, rej) => {95 ws.onopen = res;96 setTimeout(rej, 5000);97});98await send('Runtime.enable');99const ev = async (expression) => {100 const r = await send('Runtime.evaluate', {101 expression,102 awaitPromise: true,103 returnByValue: true,104 });105 if (r.error)106 throw new Error(`CDP error: ${JSON.stringify(r.error).slice(0, 400)}`);107 if (r.result?.exceptionDetails)108 throw new Error(JSON.stringify(r.result.exceptionDetails).slice(0, 400));109 return r.result?.result?.value;110};111// `require` is NOT in eval scope — always go through process.mainModule.112const REQ = 'process.mainModule.require';113// Root window = the one BrowserWindow with no parent and not the log-viewer114// window (the only other unparented window `src/main.ts` creates). Reuse115// this exact expression for every operation below — do not re-derive it.116const ROOT_WINDOW = `${REQ}('electron').BrowserWindow.getAllWindows()117 .find((w) => !w.isDestroyed() && !w.getParentWindow()118 && w.getTitle() !== 'Log Viewer - Rocket.Chat')`;119120// 1. Un-occlude so paint (and capturePage) is live121await ev(`(() => { const w = ${ROOT_WINDOW};122 w.show(); w.focus(); return 'ok'; })()`);123124// 2. Trigger real flows via menu item ids (works for any getMenuItemById id).125// Assert the gate + item are actually there before clicking — a missing126// or disabled item would otherwise silently no-op and still print 'clicked'.127await ev(`(() => { const { Menu } = ${REQ}('electron');128 const menu = Menu.getApplicationMenu();129 const devMode = menu?.getMenuItemById('developerMode');130 if (!devMode?.checked) throw new Error('developerMode gate is off');131 const item = menu.getMenuItemById('simulateDownload');132 if (!item) throw new Error('simulateDownload menu item not found');133 if (!item.enabled) throw new Error('simulateDownload menu item is disabled');134 item.click();135 return 'clicked'; })()`);136137// 3. Read renderer truth (computed styles > pixels for diagnosis)138console.log(139 await ev(`(() => { const w = ${ROOT_WINDOW};140 return w.webContents.executeJavaScript(\`(() => {141 const b = document.querySelector('button[data-downloads-status]');142 return JSON.stringify({ status: b?.getAttribute('data-downloads-status'),143 rect: b && b.getBoundingClientRect().toJSON() });144 })()\`); })()`)145);146147// 4. Screenshot a region (write PNG somewhere readable, then Read it)148await ev(`(() => { const w = ${ROOT_WINDOW};149 const [width] = w.getContentSize();150 return w.webContents.capturePage(151 { x: Math.max(0, width - 420), y: 0, width: 420, height: 34 }152 ).then((img) => { ${REQ}('fs').writeFileSync('/tmp/ui_check.png', img.toPNG());153 return 'ok'; }); })()`);154ws.close();155```156157## Gotchas158159- In the main-process inspector sandbox bare `require` may be missing — use160 `process.mainModule.require('electron')`.161- Developer-menu toggles can be driven with162 `Menu.getApplicationMenu().getMenuItemById('<id>').click()` (ids:163 `developerMode`, `simulateUpdate`, `simulateDownload`,164 `simulateDisconnected`).165- The tray `Tray` instance is module-scoped and not reachable from CDP;166 opening the tray menu needs a real click — `osascript`/System Events clicks167 require Accessibility permission for the terminal app (error -25211168 otherwise). `screencapture -x` + `sips -c` crops work without permission169 for verifying the icon itself.170- `yarn start` relaunches Electron once per bundle for ~60 s; wait for171 `waiting for changes` before screenshots.172173## Useful recipes174175- **Real download with known size** (slow mirror, good for watching the176 ring): grab a cancel handle first, then177 `wc.downloadURL('https://proof.ovh.net/files/1Gb.dat')` on a webview's178 webContents (`wc.session.once('will-download', (e, item) => { globalThis.__t = item; })`),179 cancel with `globalThis.__t.cancel()` when done.180- **DOM-truth beats screenshots for diagnosis**: `getBoundingClientRect` of181 svg children vs their svg viewport catches clipping that looks like182 "missing artwork"; `getComputedStyle(...).stroke/opacity/transition`183 catches token and animation regressions.184- The dev instance uses the `Rocket.Chat (development)` userData profile —185 its persisted settings (theme, `navigationLayout: 'tabs' | 'sidebar' |186'hidden'`) live in that profile's `config.json`; edit + restart to switch187 the layout under test (TopBar layouts only render with `sidebar`/`hidden`).