diff --git a/.claude/skills/demo-insomnia/SKILL.md b/.claude/skills/demo-insomnia/SKILL.md new file mode 100644 index 0000000000..49bd9c375a --- /dev/null +++ b/.claude/skills/demo-insomnia/SKILL.md @@ -0,0 +1,124 @@ +--- +name: demo-insomnia +description: 'Walk the user through something in the running Insomnia app one step at a time, narrating each step in chat and pausing until they say continue. Use it after reproducing a bug, after fixing one, and after finishing a feature — driving the UI at machine speed shows the user nothing, so stop and replay it for them. Also when they ask to be shown, walked through, or given a demo of anything in the app.' +--- + +You just drove the Insomnia UI at machine speed. The user watched windows flash +and learned nothing. Replay it for them, one step at a time. + +Builds on the `run-insomnia` skill — its driver must be running. `D=$S/drive.mjs` +below, as in that skill. + +**Narrate in chat. The app window is where they watch, chat is where you +explain.** Do not paint captions or highlights into the app unless the user +asks for them (see "In-app overlay" at the end). + +## The contract + +Break any of these and the demo is worthless: + +1. **Rehearse first, demo second.** Get the whole thing working at full speed + before you show anyone. A demo is a performance of a known-good script, not + an investigation. The user must never watch you guess or recover. +2. **Reset to a clean starting point, and make describing it step 1.** The + demo has to be reproducible and has to begin somewhere the user recognises. + Give the starting state its own pause, before anything moves — which + profile, what was set up for them, what is on screen now. It is the one step + that performs no action, and it earns its stop: the user cannot follow a + change they never saw the "before" of. +3. **One step per conversation turn.** Act, narrate, stop. Never run two steps + in one turn, however trivial the second looks. +4. **Stop by asking, not by waiting.** Never block the driver waiting for the + user: a blocked driver cannot answer a question, and they are entitled to + interrupt at any point. The pause is a turn boundary. +5. **Act first, then narrate.** Perform the step, then describe what happened. + Announcing an action you have not taken yet wastes a pause on information + the user cannot verify. + +## Each step + +Write the demo in English, headings included. + +Perform the action, then write exactly these three things: + +> **What I did** — the action, in terms of what a user would do, not which op +> you called. "Clicked Send, top right" not "click on request-pane Send". +> +> **What to look at** — where to look and what it means. Name the location in +> words, since there is no highlight: "the status tag at the top of the right +> pane, now reading 200 OK". If the interesting thing is an *absence*, say so — +> the user cannot see a thing that failed to happen unless you point at the gap. +> +> **Next** — one sentence. This is what removes the need for a separate +> "about to do X" step. + +Then ask the user to continue, offering **Continue / Replay this step / I have +a question**. They can also just type; answer, then re-offer. + +Keep each of the three to a sentence or two. Depth belongs in the answer to a +question they actually asked. + +**Screenshots:** the user is watching the live window, so don't attach one by +default. Attach one when the change is easy to miss, when it is the payoff +frame of a bug repro, or when they have been reading chat and probably looked +away. + +**Cross-check the important claims.** When you say the status is 200 OK, read it back +with `text` or `db` and say you did. On a bug demo this is the difference +between showing a symptom and proving it. + +## The three scripts + +**Reproducing a bug.** End on the broken state and stop there — that is the +whole point. Screenshot it. Say what *should* have happened next to what did. +Only then offer to start fixing. + +**Proving a fix.** Same steps, same order, same words as the repro. The user +already knows the script, so the only new information is the different ending; +don't make them re-learn the setup. If the repro was 5 steps, this is 5 steps. + +**Touring a feature.** The happy path first, in the order a real user meets it. +Then one thing that used to be hard and now isn't. Skip configuration nobody +will touch. + +## Writing good steps + +- Fold the setup clicking into step 1's description. "Starting point: the + smoke-test collection is imported" beats four steps of watching an import. +- Aim for 4–6 steps including the opening. Beyond that you are narrating, not + demonstrating. +- **Every later pause must correspond to a visible change of state.** Step 1 is + the only one that stops without having done anything. A step whose whole + content is "next I will click X" does not earn a stop — that sentence belongs + at the end of the previous step, which is what the **Next** line is for. +- Say why a step exists when it isn't obvious: "a request has to be sent + first, or the response pane is empty and the bug cannot trigger". +- Mention the profile when it matters. On `mock` nothing leaves the machine; on + `prod` the demo is writing to a real account. + +## In-app overlay (opt-in) + +Only when the user asks to see captions in the window — screen recording, or +showing it to someone else: + +```bash +node $D demo '{"step":2,"of":5,"text":"...","target":{"role":"button","name":"Send"}}' +node $D demo '{"clear":true}' +``` + +It paints a caption bar and rings the target element. `position: "top"` moves +the bar off Insomnia's own status bar. It does not wait for anyone — the pause +is still a turn boundary. + +Even then, keep narrating in chat: the caption holds one sentence, the +reasoning does not fit. + +## Gotchas + +- **`reload` and `relaunch` wipe the overlay**, and `quit` ends it entirely. +- **The highlight is measured once.** If the app re-renders or scrolls, re-issue + the same step rather than leaving a stale ring. +- **Don't demo on `prod` if the script writes anything you wouldn't want kept.** + Use `mock`, or a scratch project created for the purpose. +- If a rehearsed step fails mid-demo, say so plainly and stop. Do not improvise + a different route in front of the user — rehearse again first. diff --git a/.claude/skills/run-insomnia/.gitignore b/.claude/skills/run-insomnia/.gitignore new file mode 100644 index 0000000000..cca779ab0e --- /dev/null +++ b/.claude/skills/run-insomnia/.gitignore @@ -0,0 +1,3 @@ +# Backend URLs for non-public environments. This repo is public — keep them out +# of it. Start from profiles.example.json. +profiles.json diff --git a/.claude/skills/run-insomnia/SKILL.md b/.claude/skills/run-insomnia/SKILL.md new file mode 100644 index 0000000000..bf42cd1fb0 --- /dev/null +++ b/.claude/skills/run-insomnia/SKILL.md @@ -0,0 +1,429 @@ +--- +name: run-insomnia +description: 'Launch the real Insomnia Electron app and drive its UI — click, type, ARIA snapshot, screenshot, read its NeDB store — against the production, dev, staging or mock backend, already signed in. Use for any hands-on work in the Insomnia desktop repo. Reach for it when implementing a feature or fixing a bug there, both to reproduce the problem first and to confirm the change actually works in the running app before calling it done, since unit tests alone do not establish that. Also when asked to run, start, screenshot, or log in to Insomnia.' +--- + +Insomnia is an Electron app. To verify a change the way a developer would — +open the app, click through it, look at the result — drive it through the +Playwright `_electron` driver in this folder. + +The driver is a **long-running process with an HTTP control channel**, so the +app launches once (~20s) and then every interaction is a cheap one-line shell +call. A throwaway Playwright test would relaunch the app on every iteration. + +The driver finds the checkout to drive from its **working directory** (walking +up for `packages/insomnia/package.json`) rather than from its own location, so +it runs correctly from either place you might keep it: + +```bash +S=.claude/skills/run-insomnia # this repo — shared with the team, updated by git +S=~/.claude/skills/run-insomnia # a personal copy, if you prefer one install for every worktree +``` + +`S` is used throughout this document; paths are otherwise relative to the +worktree root. Override the detected checkout with `INSOMNIA_REPO=`. + +Keeping a personal copy as well as the committed one means two skills share a +name — pick one. + +## Prerequisites (once per worktree) + +```bash +node -v # must match .nvmrc (fnm use "$(cat .nvmrc)") +npm ci # NOT --ignore-scripts: that leaves Electron half-installed +ls node_modules/electron/dist/electron* # if missing: node node_modules/electron/install.js +``` + +## Start the three background processes + +```bash +cd +npm run watch:app # builds the main/preload bundles ONCE, then runs the Vite dev server forever +node $S/driver.mjs # Control channel on 127.0.0.1:9800; prints the repo it resolved +npm run serve -w insomnia-smoke-test # Echo server on :4010 — only needed for the `mock` profile +``` + +Check the driver's `repo=` line is the worktree you meant. `status` and `whoami` +repeat it. + +To drive two worktrees at once, give the second one its own port **and** its own +data dir — `~/.insomnia-driver/` is shared, and Electron's +single-instance lock is keyed on it: + +```bash +DRIVER_PORT=9801 node $S/driver.mjs +DRIVER_PORT=9801 node $S/drive.mjs launch '{"dataPath":"/tmp/insomnia-wt2"}' +``` + +Wait for `VITE ... ready in` before launching (and `Listening at http` too, if +you are going to use `mock`). + +Despite its name `watch:app` only watches the *renderer*. It is +`build:electron-entrypoints && start:dev-server`: esbuild compiles +`src/entry.{main,preload,hidden-window,plugin-window}*.ts` once and exits, then +`vite dev` stays up and writes `packages/insomnia/.vite-port`. The main process +reads that file in `window-utils.ts` and `loadURL`s the dev server, which is why +the port file is the launch handshake — and why `launch` refuses to start +without it. + +Don't use `npm run dev` / `dev:autoRestart` here: both spawn their own Electron, +which would race the one the driver owns. + +## Drive it + +```bash +D=$S/drive.mjs +node $D launch # opens the app, signed in as the smoke-test account, fresh data dir +node $D snapshot # ARIA tree as YAML — read this to decide what to click +node $D click '{"target":{"role":"button","name":"Send"}}' +node $D screenshot '{"name":"after-send"}' # -> %TEMP%/insomnia-shots/after-send.png, then Read it +``` + +`snapshot` before `screenshot`. The ARIA tree names every clickable element and +costs a fraction of the tokens an image does; use a screenshot when the question +is visual (layout, colour, overlap) or when a snapshot doesn't explain what you see. + +### Ops + +| op | args | what it does | +|---|---|---| +| `launch` | `profile?`, `dataPath?`, `fresh?`, `fixtureDb?`, `settings?`, `env?`, `display?` | start the app | +| `moveWindow` | `display` | move the app window to another monitor, see below | +| `profiles` `whoami` | | list backends / show account + backend in use | +| `login` `submitCode` `waitForSession` | `provider?` / `code` | real browser login, see below | +| `reload` | | reload the renderer only — picks up `src/ui/**` edits in ~3s | +| `relaunch` | same as launch | full restart, same data dir — for `src/main/**` edits and cold-start checks (rebuild first, see below) | +| `quit` / `status` | | close / inspect | +| `snapshot` | `target?` | ARIA tree (YAML) | +| `screenshot` | `name?`, `target?`, `fullPage?` | PNG path to `Read` | +| `click` `hover` `fill` `type` `press` | `target`, `value`/`text`/`key` | interact | +| `text` `count` `wait` | `target`, `state?` | assert | +| `eval` `mainEval` | `expr` | renderer / main-process JS (`mainEval` takes a *function expression*) | +| `netlog` `throttle` | `action`/`pattern` / `latency` | watch and slow Insomnia's own HTTP, see below | +| `db` | `type`, `where?`, `fields?` | read the live NeDB store | +| `logs` `errors` | `type?`, `limit?` | renderer console + uncaught errors | +| `importFixture` | `file` | import a `packages/insomnia-smoke-test/fixtures` file via the real import UI | +| `setClipboard` | `text` | write to the OS clipboard | +| `sleep` | `ms` | | +| `demo` | `text`, `step?`, `of?`, `target?`, `position?` | caption + highlight in the app window, for a human watching | +| `exec` | `file` or `code` | **escape hatch — arbitrary Playwright code, see below** | +| `note` `feedback` | `text` / `limit?`, `kind?` | report / read driver friction, see below | + +### Which monitor the app opens on + +On launch the window is moved to the **rightmost** display and maximised there. +The person watching is usually reading this conversation in an editor, and an +Insomnia window landing on top of it buries the very text explaining what the +app is doing. + +```bash +node $D launch '{"display":"left"}' # right (default) | left | primary | | none +node $D moveWindow '{"display":"primary"}' +``` + +Change the default for a session with `DRIVER_DISPLAY=primary node $S/driver.mjs`, +and pass `"none"` to leave the window wherever Electron put it. + +With one monitor this just maximises — there is nowhere else to go, so use +`"none"` if that is in the way. + +### Profiles — which backend the app talks to + +`launch` takes a `profile`. **`prod` is the default** — this app is developed +against the real backend day to day, and every real profile stays logged in, so +that is the cheap path. Override the default for a session with +`DRIVER_PROFILE=dev node driver.mjs`. + +| profile | backend | data dir | +|---|---|---| +| `prod` *(default)* | `api.insomnia.rest` / `app.insomnia.rest`, real login | `~/.insomnia-driver/prod`, **kept** | +| `dev`, `staging`, ... | whatever `profiles.json` defines, real login | `~/.insomnia-driver/`, **kept** | +| `mock` | echo server on :4010, injected fake session | temp, **wiped every launch** | + +Reach for `mock` when you are offline, reproducing a smoke-test scenario, or +deliberately want a blank slate that can't touch real data. + +Add `dev`, `staging` or a self-hosted instance in `profiles.json` next to +`driver.mjs` — copy `profiles.example.json` to start. `profiles.json` is +gitignored: this repo is public, so internal hostnames stay out of it; ask a +teammate for the real values. Profiles and their logins live under +`~/.insomnia-driver/`, so they are **shared by every worktree and survive a +reinstall**: sign in once, use it everywhere. Mirror the +env vars from however the team runs `npm run dev` against that stack; anything +left out falls back to the **production** default in `common/constants.ts`, so +omitting e.g. `KONNECT_API_URL` points that one service at prod: + +```json +{ "dev": { "description": "internal dev stack", + "env": { "INSOMNIA_API_URL": "https://api.dev.example", + "INSOMNIA_APP_WEBSITE_URL": "https://app.dev.example", + "KONNECT_API_URL": "https://api.dev.example" } } } +``` + +`prod` works by setting **nothing**: `common/constants.ts` already defaults to +production, so an empty `env` is the production config. The driver first strips +every backend-steering var (`MANAGED_ENV_KEYS`) out of the inherited environment, +so a stray `INSOMNIA_API_URL` in your shell can't silently redirect a run. + +Switching profiles needs `quit` then `launch` — one app per driver. A data dir +records its owning profile in `.driver-profile` and `launch` refuses a mismatch: +starting `mock` against a real profile's dir would write the fake +`INSOMNIA_SESSION` over the real login. `relaunch` carries the profile across for +the same reason. + +### Real login, and reusing it + +`entry.main.ts` does `app.setPath('userData', INSOMNIA_DATA_PATH)`, so the NeDB +store *and* the Chromium localStorage live in the profile's data dir. A +non-ephemeral profile therefore **keeps its login across relaunches and driver +restarts** — log in once per profile, not once per run. + +```bash +node $D launch '{"profile":"prod"}' +node $D whoami # loggedIn: true? then you are done +node $D login # -> { available: [Google, GitHub, Email, SSO], url } +node $D login '{"provider":"GitHub"}' # clicks it; the app opens your browser +# ...user signs in... +node $D waitForSession # polls the data dir, up to 5 min +``` + +`login` clicks the app's own provider button rather than opening the URL itself: +that is what routes the app to `/auth/login-tip` → `/auth/authorize`, and it +exercises the same code path a user would. The driver parks on the +paste-token screen so the fallback is ready. + +If the browser never returns to the app, copy the token the site shows: + +```bash +node $D submitCode '{"code":""}' +``` + +**Do not `reload`, `relaunch` or restart the driver between `login` and the token +exchange.** `auth-session-provider.client.ts` calls `sealedBox.keyPair()` at +module scope and overwrites `localStorage` on *every* page load, so a reload +invalidates the token the website just issued. Once the session exists it is in +NeDB and survives everything. + +The deep-link callback is `insomniadev://` in dev builds (`insomnia://` only in +packaged ones), so it will not collide with an installed Insomnia. It is +registered from `process.argv`, which is why the driver launches Electron with +`.` rather than the entry file — a relative entry path would register a command +line that only resolves from one cwd. + +### exec — the whole Playwright API + +The ops above are shorthand for the common cases. `exec` is the escape hatch: +anything in the Playwright API, any number of steps, in **one** round trip. + +Preferred form — write a `.ts` file and pass its path. No shell or JSON escaping, +and the driver's TS loader lets it import the smoke suite's Page Objects: + +```ts +// scripts/my-flow.ts — must `export default` an async function +import { InsomniaApp } from '../../../../packages/insomnia-smoke-test/playwright/pages'; + +export default async ({ page, app, expect, log, ops }) => { + const insomnia = new InsomniaApp(page, app); + await insomnia.projectPage.importFixture('smoke-test-collection.yaml'); + await insomnia.navigationSidebar.clickRequestOrFolder('send JSON request'); + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expect(page.getByTestId('response-status-tag')).toContainText('200 OK'); + log('sent ok'); + return await ops.db({ type: 'Response', fields: ['statusCode', 'url'] }); +}; +``` + +```bash +node $D exec scripts/my-flow.ts # resolved against the worktree, then the skill dir +``` + +`exec` resolves a relative path against the worktree first, then against the +skill directory — so `exec scripts/example-flow.ts` reaches the bundled examples +and `exec tmp/my-flow.ts` reaches one you wrote in the checkout. + +Import repo sources with the **`@repo/`** prefix rather than a relative path; +the skill lives outside the checkout, so `../../..` would not reach it: + +```ts +import { InsomniaApp } from '@repo/packages/insomnia-smoke-test/playwright/pages'; +``` + +See [scripts/example-flow.ts](scripts/example-flow.ts) and +[scripts/goto-login.ts](scripts/goto-login.ts) — both run as-is. + +One-liners can go inline instead, as the body of an async function with the +context pre-destructured: + +```bash +node $D exec '{"code":"return await page.getByRole(\"tab\").allInnerTexts()"}' +``` + +`mainEval` runs in the main process and takes a **function expression**, called +with the electron module as its argument. There is no `require` in there: + +```bash +node $D mainEval '{"expr":"({ app }) => app.getPath(\"userData\")"}' +node $D mainEval '{"expr":"async ({ session }) => await session.defaultSession.getCacheSize()"}' +``` + +Context: `page`, `app`, `expect` (auto-retrying assertions from `@playwright/test`), +`ops` (every op in the table above), `locate` (the JSON target resolver), `log` +(collects values into the response), `dataPath`, `REPO`, `APP_CWD`, `FIXTURES`. + +`log()` output and the return value both come back; a thrown error returns the +message, the stack, **and** the logs collected before it threw, so a failing +script still shows how far it got. The return value must be JSON-serialisable — +a Locator comes back as its string form, so return data, not handles. + +Reach for `exec` whenever a flow is more than ~3 steps, or needs a Playwright +feature the ops don't cover. **Don't extend `driver.mjs` for one-off needs** — +that costs a driver restart, which kills the running app. + +### Watching and slowing Insomnia's own network + +**Renderer-side interception does not work on this app.** Insomnia takes over +the `http:` and `https:` schemes itself in +`packages/insomnia/src/main/api.protocol.ts:139,160` and re-issues every request +from the main process via `net.fetch`. The request the page sees is a stub that +never reaches the wire. So: + +- `page.on('request')` / `page.route()` record traffic only intermittently — + they catch the stub when timing happens to favour them, and miss it otherwise. + A run that records 5 calls and a run that records 0 can be the same flow. +- `route.continue()` delays, `session.enableNetworkEmulation()` and CDP + `Network.emulateNetworkConditions` all measure as **no-ops**. All three were + tried; re-run [scripts/probe-throttle.ts](scripts/probe-throttle.ts) if you + want to see it for yourself. + +`session.defaultSession.webRequest` in the main process does see it, and that is +what these two ops are built on: + +```bash +node $D netlog '{"action":"start","pattern":"konghq"}' # pattern filters while recording +# ...reproduce the flow... +node $D netlog '{"action":"read"}' # url, method, status, ms +node $D netlog '{"action":"stop"}' + +node $D throttle '{"latency":1500}' # make a race reproducible +node $D throttle '{"off":true}' +``` + +Two things to expect: + +- **Every API call shows up twice** — once as the renderer's original + (`type: "xhr"`) and once as main's re-issue (`type: "other"`). That is the + interception, not a retry. +- **Latency is applied per event, so end-to-end delay is roughly double** what + you ask for. Measured: 215ms baseline -> ~3200ms at `latency: 1500`. Dial it + in against a `netlog` reading instead of trusting the number. + +Filter with `pattern` at `start`, not at `read`: Sentry's `sentry-ipc://` +protocol alone emits hundreds of entries a minute and will evict real traffic +from the buffer. Non-http schemes are dropped unless you pass `all: true`. + +Neither op sees the requests the **user** sends from the app — those go through +node-libcurl, outside Chromium. Use the echo server's log or `db` with +`{"type":"Response"}` for those. + +### Targets + +`target` is a JSON description, resolved to a Playwright locator: + +```json +{"role":"button","name":"Send","exact":true} +{"testid":"response-status-tag"} +{"css":"[data-testid=\"response-pane\"] [data-testid=\"CodeEditor\"]:visible"} +{"role":"button","name":"Send","within":{"testid":"request-pane"}} +``` + +Keys: `css` | `role`+`name` | `testid` | `label` | `placeholder` | `title` | `text`, +plus `within` (scope), `hasText`, `exact`, `nth`/`first`/`last`. A bare string is CSS. + +Insomnia is built on React Aria, so `role`+`name` works almost everywhere, and the +codebase is well covered by `data-testid`. Copy targeting patterns from +`packages/insomnia-smoke-test/tests/smoke/*.test.ts` — the Page Objects in +`packages/insomnia-smoke-test/playwright/pages/` are the maintained reference for +multi-step flows (import, git sync, preferences). + +## Report anything that got in your way + +This driver is maintained in a separate worktree and session from the one you +are probably in. Per-project memory does not cross worktrees, so the one channel +that reaches its maintainer is the friction log at +`~/.insomnia-driver/feedback.jsonl`. + +**Every failed op is appended there automatically** — you don't have to do +anything for those. What the log cannot see is intent, so add it yourself the +moment the driver costs you time: + +```bash +node $D note '{"text":"wanted to drag a request between folders; no op for it and locator.dragTo timed out on the tree"}' +``` + +Worth a `note`: + +- an op you expected to exist and didn't +- a selector that took several tries, especially if the ARIA name was misleading +- a gotcha that cost you a debugging cycle and is not in the Gotchas below +- anything you worked around with `exec` that deserves promoting to an op + +Not worth a `note`: one-off app bugs you are there to fix. This log is about +the *tooling*, not the product. + +Pass `{"quiet":true}` on an op you expect to fail (probing whether an element +exists) so it stays out of the log. + +## The loop + +1. `snapshot` the current screen, reproduce the bug, `screenshot` the evidence. +2. **Stop and replay the repro for the user** with the `demo-insomnia` skill, + before writing any fix. You just drove the UI faster than anyone can read; + the user saw windows flash and learned nothing. This is not optional and + they should not have to ask for it. +3. Edit the code. +4. `src/ui/**` -> `reload`. `src/main/**` -> `npm run build:electron-entrypoints -w insomnia`, then `relaunch`. Never restart the driver. +5. `snapshot` / `text` / `db` to confirm the new behaviour. +6. **Replay the same script for the user** (`demo-insomnia` again) — identical + steps, different ending. That is what proves the fix, not a passing test. + Same for a finished feature: walk them through using it. +7. Regression gate: `npm run test:smoke:dev -- `. It reuses the + already-running Vite and echo servers, so it costs no extra setup. +8. `npm run lint && npm run type-check && npm test`. +9. `note` anything about the driver that slowed you down (see above). + +## Gotchas + +- **Editing `driver.mjs` needs a driver restart, which kills the app.** This is + why `exec` exists — new capability should go in an exec script, not a new op. +- **Edits to this skill affect every worktree.** There is one copy. Prefer an + exec script over changing the driver. +- **Exec scripts are cache-busted, the modules they import are not.** Editing + `my-flow.ts` and re-running picks the edit up; editing a Page Object it imports + needs a driver restart. +- **`src/main/**` edits are not picked up by anything automatically.** esbuild in + `watch:app` is a one-shot build, not a watcher. Re-run + `npm run build:electron-entrypoints -w insomnia` and then `relaunch`. Only the + renderer (`src/ui/**`) is live, because Vite serves it. +- **`strict mode violation` is the normal failure.** The error lists every match + with its accessible name — add `exact`, `within`, or `nth` and retry. Don't + reach for `force`. +- **CodeMirror fields ignore `fill`.** URL bars, body editors and the import + dialog are CodeMirror; use `type` (which clicks, then sends real keystrokes). +- **`launch` wipes its data dir by default.** Pass `dataPath` to keep state across + driver restarts, or `fixtureDb` to start from a seeded store. +- **The default profile touches real cloud data.** `prod` means UI actions create, + modify and delete things in a real account. Creating scratch collections is + routine; confirm deletions and edits to pre-existing data with the user first. + Only `mock` is sealed off — every Insomnia API env var points at + `localhost:4010` there, so nothing leaves the machine. +- **`fresh: true` on a real profile deletes its login.** Wiping is automatic only + for `mock`; on `prod` it has to be asked for, and it costs you a re-login. +- **`launch` fails with "Target page ... has been closed" -> a stray Electron holds + the single-instance lock.** Deep linking (needed for the login callback) turns on + `requestSingleInstanceLock()`, so one leftover process blocks every new launch. + `taskkill /f /im electron.exe` (Windows) / `pkill -f electron` and retry. +- **Closing the app window by hand leaves the driver's handles stale.** `launch` + detects it and restarts, but `status` will show `windows: []` until then. +- **NeDB appends, so a clobbered doc is recoverable.** Every write is a new line in + `insomnia..db` and the last line for an `_id` wins; truncating the file back + to the good line restores the earlier state. diff --git a/.claude/skills/run-insomnia/drive.mjs b/.claude/skills/run-insomnia/drive.mjs new file mode 100644 index 0000000000..5b0c0fabc3 --- /dev/null +++ b/.claude/skills/run-insomnia/drive.mjs @@ -0,0 +1,67 @@ +/** + * Thin CLI client for driver.mjs. + * + * node .claude/skills/run-insomnia/drive.mjs [''] + * + * Examples: + * node drive.mjs launch + * node drive.mjs snapshot + * node drive.mjs click '{"target":{"role":"button","name":"Send"}}' + * node drive.mjs db '{"type":"Request","fields":["_id","name","url"]}' + * + * Prints the driver's JSON response. Exits non-zero when the op failed, so it + * composes with `&&` in shell pipelines. + */ +const [, , op, rawArgs] = process.argv; + +if (!op) { + console.error("usage: node drive.mjs ['']"); + process.exit(2); +} + +const port = process.env.DRIVER_PORT || 9800; + +let args = {}; +if (rawArgs) { + // `exec` takes a script path directly: `drive.mjs exec tmp/flow.ts` - no JSON, + // no shell escaping. Anything starting with `{` is still parsed as JSON args. + if (op === 'exec' && !rawArgs.trimStart().startsWith('{')) { + args = { file: rawArgs }; + } else { + try { + args = JSON.parse(rawArgs); + } catch (e) { + console.error('args must be valid JSON: ' + e.message); + process.exit(2); + } + } +} + +const res = await fetch('http://127.0.0.1:' + port + '/' + op, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(args), +}).catch(e => { + console.error('cannot reach driver on port ' + port + ' (' + e.message + '). Is driver.mjs running?'); + process.exit(3); +}); + +const json = await res.json(); + +// `snapshot` is YAML; printing it raw keeps it readable instead of JSON-escaped. +if (json.ok && typeof json.snapshot === 'string') { + console.log(json.snapshot); +} else { + // exec's `log()` output first, as plain lines - that is the script talking. + if (Array.isArray(json.logs) && json.logs.length && op === 'exec') { + for (const line of json.logs) { + console.log(line); + } + const { logs: _logs, ...rest } = json; + console.log(JSON.stringify(rest, null, 2)); + } else { + console.log(JSON.stringify(json, null, 2)); + } +} + +process.exit(json.ok ? 0 : 1); diff --git a/.claude/skills/run-insomnia/driver.mjs b/.claude/skills/run-insomnia/driver.mjs new file mode 100644 index 0000000000..1a2ed59e83 --- /dev/null +++ b/.claude/skills/run-insomnia/driver.mjs @@ -0,0 +1,1516 @@ +/** + * Agent driver for the Insomnia Electron app. + * + * Long-running process: launches Insomnia once via Playwright's `_electron` + * and exposes an HTTP control channel, so an agent can drive the live UI across + * many separate shell calls without paying the ~20s startup cost each time. + * + * Start: node .claude/skills/run-insomnia/driver.mjs (run in background) + * Control: node .claude/skills/run-insomnia/drive.mjs '' + */ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import { createRequire, register } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +/** + * The Insomnia checkout to drive. Derived from the working directory rather than + * from this file's location, so one copy of this skill installed under + * ~/.claude/skills/ serves every git worktree: `cd && node `. + */ +function findRepoRoot(start) { + let dir = path.resolve(start); + for (;;) { + if (fs.existsSync(path.join(dir, 'packages', 'insomnia', 'package.json'))) { + return dir; + } + const up = path.dirname(dir); + if (up === dir) { + return null; + } + dir = up; + } +} + +const REPO = process.env.INSOMNIA_REPO || findRepoRoot(process.cwd()); +if (!REPO) { + console.error( + 'No Insomnia checkout found from ' + process.cwd() + '. ' + + 'Start the driver from inside a worktree, or set INSOMNIA_REPO=.', + ); + process.exit(1); +} + +// Resolve dependencies out of the *repo's* node_modules. This file lives outside +// any package, so bare imports would search ~/.claude/** and find nothing. Sharing +// the repo's playwright-core is also what keeps the Locator instances an exec +// script sees identical to the driver's. +const repoRequire = createRequire(path.join(REPO, 'package.json')); +const { expect } = repoRequire('@playwright/test'); +const { _electron: electron } = repoRequire('playwright-core'); + +const APP_CWD = path.join(REPO, 'packages', 'insomnia'); +const FIXTURES = path.join(REPO, 'packages', 'insomnia-smoke-test', 'fixtures'); +const PORT = Number(process.env.DRIVER_PORT || 9800); +const SHOT_DIR = process.env.SHOT_DIR || path.join(os.tmpdir(), 'insomnia-shots'); +fs.mkdirSync(SHOT_DIR, { recursive: true }); + +const electronRelative = { + win32: 'electron.exe', + darwin: 'Electron.app/Contents/MacOS/Electron', +}[process.platform] || 'electron'; +const ELECTRON_BIN = path.join(REPO, 'node_modules', 'electron', 'dist', electronRelative); + +const ECHO = 'http://localhost:4010'; + +/** @type {import('playwright-core').ElectronApplication | null} */ +let app = null; +/** @type {import('playwright-core').Page | null} */ +let page = null; +let dataPath = null; +let currentProfile = null; +let consoleLog = []; +let pageErrors = []; + +// The same offline test account the smoke tests sign in with. +const SESSION = { + id: 'sess_64a477e6b59d43a5a607f84b4f73e3ce', + sessionExpiry: new Date(2147483647000), + publicKey: { + alg: 'RSA-OAEP-256', + e: 'AQAB', + ext: true, + key_ops: ['encrypt'], + kty: 'RSA', + n: 'pTQVaUaiqggIldSKm6ib6eFRLLoGj9W-2O4gTbiorR-2b8-ZmKUwQ0F-jgYX71AjYaFn5VjOHOHSP6byNAjN7WzJ6A_Z3tytNraLoZfwK8KdfflOCZiZzQeD3nO8BNgh_zEgCHStU61b6N6bSpCKjbyPkmZcOkJfsz0LJMAxrXvFB-I42WYA2vJKReTJKXeYx4d6L_XGNIoYtmGZit8FldT4AucfQUXgdlKvr4_OZmt6hgjwt_Pjcu-_jO7m589mMWMebfUhjte3Lp1jps0MqTOvgRb0FQf5eoBHnL01OZjvFPDKeqlvoz7II9wFNHIKzSvgAKnyemh6DiyPuIukyQ', + }, + encPrivateKey: { iv: '', t: '', d: '', ad: '' }, + symmetricKey: { + alg: 'A256GCM', + ext: true, + k: 'w62OJNWF4G8iWA8ZrTpModiY8dICyHI7ko1vMLb877g=', + key_ops: ['encrypt', 'decrypt'], + kty: 'oct', + }, + email: 'insomnia-user@konghq.com', + accountId: 'acct_64a477e6b59d43a5a607f84b4f73e3ce', + firstName: 'Rick', + lastName: 'Morty', +}; + +// --------------------------------------------------------------------------- +// Profiles: which backend the app talks to +// --------------------------------------------------------------------------- + +/** + * Every env var that steers Insomnia at a backend. Stripped from the inherited + * environment before a profile's own values are applied, so a stray + * `INSOMNIA_API_URL` in the developer's shell can't silently point a "prod" run + * somewhere else — or leak echo-server values into a real-account run. + */ +const MANAGED_ENV_KEYS = [ + 'INSOMNIA_API_URL', + 'INSOMNIA_APP_WEBSITE_URL', + 'INSOMNIA_AI_URL', + 'INSOMNIA_MOCK_API_URL', + 'INSOMNIA_GITHUB_REST_API_URL', + 'INSOMNIA_GITHUB_API_URL', + 'INSOMNIA_GITLAB_API_URL', + 'INSOMNIA_UPDATES_URL', + 'KONNECT_API_URL', + 'KONNECT_API_REGIONS', + 'OAUTH_REDIRECT_URL', + 'OAUTH_RELAY_URL', + 'INSOMNIA_SESSION', + 'INSOMNIA_PUBLIC_KEY', + 'INSOMNIA_SECRET_KEY', + 'INSOMNIA_VAULT_KEY', + 'INSOMNIA_VAULT_SALT', + 'INSOMNIA_VAULT_SRP_SECRET', + 'INSOMNIA_SKIP_ONBOARDING', +]; + +const BUILTIN_PROFILES = { + mock: { + description: 'local echo server + injected fake session. Nothing leaves the machine.', + // Wiped on every launch so tests start from a known state. + ephemeral: true, + env: { + INSOMNIA_API_URL: ECHO, + INSOMNIA_APP_WEBSITE_URL: ECHO + '/website', + INSOMNIA_AI_URL: ECHO + '/ai', + INSOMNIA_GITHUB_REST_API_URL: ECHO + '/github-api/rest', + INSOMNIA_GITHUB_API_URL: ECHO + '/github-api/graphql', + INSOMNIA_GITLAB_API_URL: ECHO + '/gitlab-api', + INSOMNIA_UPDATES_URL: ECHO, + INSOMNIA_MOCK_API_URL: 'https://mock-stage.insomnia.run', + KONNECT_API_URL: ECHO, + KONNECT_API_REGIONS: 'us', + INSOMNIA_SKIP_ONBOARDING: 'true', + INSOMNIA_PUBLIC_KEY: 'txb/w8DASTpPQqeHE/hpI3ABKzit+pv5n2We5dbtYRo=', + INSOMNIA_SECRET_KEY: 'Tb1QKsI3wVZxhS8TuQESHB2x7f68PzeTzTMmLpnnFVU=', + INSOMNIA_SESSION: JSON.stringify(SESSION), + }, + }, + prod: { + description: 'REAL production backend (api.insomnia.rest) with a real account. Touches real cloud data.', + // No overrides at all: constants.ts already defaults to production, and an + // empty env is what makes the app generate its own login keypair. + env: {}, + }, +}; + +/** + * Default backend for `launch` with no `profile`. Production, because that is + * where this app is actually developed against day to day; `mock` is the + * exception, for offline work and for reproducing smoke-test scenarios. + * Override per session with DRIVER_PROFILE=dev. + */ +const DEFAULT_PROFILE = process.env.DRIVER_PROFILE || 'prod'; + +/** + * Extra profiles (dev, staging, a self-hosted instance) come from + * `profiles.json` next to this file: { "": { "description": "...", "env": { ... } } }. + * Not committed - it tends to hold internal hostnames. + */ +function loadProfiles() { + const file = path.join(HERE, 'profiles.json'); + let custom = {}; + if (fs.existsSync(file)) { + try { + custom = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (e) { + throw new Error('profiles.json is not valid JSON: ' + e.message); + } + } + return { ...BUILTIN_PROFILES, ...custom }; +} + +/** Persistent profiles keep their data dir - that is what makes a login survive. */ +function profileDataPath(name) { + return path.join(os.homedir(), '.insomnia-driver', name); +} + +// --------------------------------------------------------------------------- +// Friction log +// --------------------------------------------------------------------------- + +/** + * Cross-worktree channel for "this driver got in my way". + * + * Per-project memory lives under ~/.claude/projects//, so a session + * working in another worktree cannot leave a note that a session here would + * read. This file is outside any checkout and shared by every worktree, so it + * is the one place feedback survives the jump. + * + * Every failed op lands here automatically; `note` adds a human/agent remark. + * Read it back with `feedback`. + */ +const FEEDBACK_LOG = path.join(os.homedir(), '.insomnia-driver', 'feedback.jsonl'); +const FEEDBACK_MAX_LINES = 1000; + +// Failures that are answers, not friction: probes and guards the caller asked for. +const NOT_FRICTION = new Set(['help', 'status', 'profiles', 'feedback', 'note', 'whoami', 'count', 'logs', 'errors']); + +function appendFeedback(entry) { + try { + fs.mkdirSync(path.dirname(FEEDBACK_LOG), { recursive: true }); + fs.appendFileSync( + FEEDBACK_LOG, + JSON.stringify({ ts: new Date().toISOString(), repo: REPO, profile: currentProfile, ...entry }) + '\n', + ); + // Cheap cap: rewrite only once the file is well past the limit. + const lines = fs.readFileSync(FEEDBACK_LOG, 'utf8').split('\n').filter(Boolean); + if (lines.length > FEEDBACK_MAX_LINES * 1.5) { + fs.writeFileSync(FEEDBACK_LOG, lines.slice(-FEEDBACK_MAX_LINES).join('\n') + '\n'); + } + } catch { + // Never let logging break an op. + } +} + +/** + * What was on screen when an op failed. Time-boxed and swallowing its own + * errors: this runs on a path that is already failing, and must never turn a + * useful error into a driver crash or a hang. + */ +async function captureContext() { + if (!page || page.isClosed()) { + return { screen: '(no window)' }; + } + const ctx = {}; + try { + ctx.url = page.url(); + } catch {} + try { + const snapshot = await Promise.race([ + page.locator('body').ariaSnapshot({ timeout: 2000 }), + new Promise(resolve => setTimeout(() => resolve(null), 2500)), + ]); + if (snapshot) { + ctx.screen = snapshot.slice(0, 1200); + } + } catch {} + return ctx; +} + +// --------------------------------------------------------------------------- +// Window placement +// --------------------------------------------------------------------------- + +/** + * Where to put the app window on launch. The driver is usually being watched + * from an editor on another monitor, and an Insomnia window that lands on top + * of that editor buries the very conversation describing what it is doing. + * + * 'right' | 'left' | 'primary' | | 'none' to leave it alone. + */ +const DEFAULT_DISPLAY = process.env.DRIVER_DISPLAY || 'right'; + +/** Moves the main window onto the requested display and maximises it there. */ +async function placeWindow(preference) { + const want = preference === undefined ? DEFAULT_DISPLAY : preference; + if (want === 'none' || want === false) { + return { placed: false }; + } + return app.evaluate(({ BrowserWindow, screen }, pref) => { + const displays = screen.getAllDisplays(); + let target; + if (typeof pref === 'number') { + target = displays[pref]; + } else if (pref === 'primary') { + target = screen.getPrimaryDisplay(); + } else if (pref === 'left') { + target = displays.reduce((a, b) => (b.bounds.x < a.bounds.x ? b : a)); + } else { + target = displays.reduce((a, b) => (b.bounds.x > a.bounds.x ? b : a)); + } + if (!target) { + return { placed: false, reason: 'no display matched ' + JSON.stringify(pref) }; + } + // The visible app window, not the hidden plugin/worker windows. + const win = BrowserWindow.getAllWindows().find(w => w.isVisible()) || BrowserWindow.getAllWindows()[0]; + if (!win) { + return { placed: false, reason: 'no window' }; + } + // Bounds first, then maximize: maximize() targets whichever display the + // window currently sits on, so moving afterwards would undo it. + win.setBounds(target.workArea); + win.maximize(); + return { + placed: true, + displays: displays.length, + on: { x: target.bounds.x, y: target.bounds.y, width: target.bounds.width, height: target.bounds.height }, + primary: target.id === screen.getPrimaryDisplay().id, + }; + }, want); +} + +// --------------------------------------------------------------------------- +// Network hooks (shared by netlog and throttle) +// --------------------------------------------------------------------------- + +/** + * Installs one set of `session.defaultSession.webRequest` listeners in the main + * process, driven by `globalThis.__driverNet`. Idempotent. + * + * Electron permits a single listener per webRequest event per session, so + * `netlog` and `throttle` must share them; installing separately would mean + * whichever ran last silently disabled the other. + */ +async function ensureNetHooks() { + if (!app) { + throw new Error('not launched'); + } + await app.evaluate(({ session }) => { + const g = globalThis; + if (g.__driverNet && g.__driverNet.installed) { + return; + } + g.__driverNet = { + installed: true, + recording: false, + pattern: null, + all: false, + latencyMs: 0, + latencyPattern: null, + log: [], + seq: 0, + }; + const pending = new Map(); + const filter = { urls: [''] }; + const wr = session.defaultSession.webRequest; + + const inScope = (url, pattern, all) => { + if (!all && !url.startsWith('http')) { + return false; + } + return !pattern || url.includes(pattern); + }; + + wr.onBeforeRequest(filter, (details, cb) => { + const n = g.__driverNet; + // Filter while recording, not on read: Sentry's IPC protocol alone emits + // hundreds of entries a minute and would evict the real traffic. + if (n.recording && inScope(details.url, n.pattern, n.all)) { + const rec = { + n: ++n.seq, + method: details.method, + url: details.url, + type: details.resourceType, + start: Date.now(), + }; + pending.set(details.id, rec); + n.log.push(rec); + if (n.log.length > 1000) { + n.log.shift(); + } + } + // Holding the callback is the only delay that actually reaches the wire + // here, because the request is re-issued from main by the protocol handler. + if (n.latencyMs > 0 && inScope(details.url, n.latencyPattern, n.all)) { + setTimeout(() => cb({}), n.latencyMs); + return; + } + cb({}); + }); + + wr.onCompleted(filter, details => { + const rec = pending.get(details.id); + if (rec) { + rec.status = details.statusCode; + rec.ms = Date.now() - rec.start; + rec.fromCache = details.fromCache; + pending.delete(details.id); + } + }); + + wr.onErrorOccurred(filter, details => { + const rec = pending.get(details.id); + if (rec) { + rec.error = details.error; + rec.ms = Date.now() - rec.start; + pending.delete(details.id); + } + }); + }); +} + +/** Collapses volatile detail so repeated failures group together. */ +function errorSignature(message) { + return String(message) + .split('\n')[0] + .replace(/\d+ms/g, '') + .replace(/\b\d{3,}\b/g, '') + .slice(0, 160); +} + +function buildEnv({ profile, dataPath, extraEnv = {} }) { + const { ELECTRON_RUN_AS_NODE: _drop, ...base } = process.env; + for (const key of MANAGED_ENV_KEYS) { + delete base[key]; + } + return { + ...base, + INSOMNIA_DATA_PATH: dataPath, + // Only enables the file-dialog stub queue (main/ipc/electron.ts); harmless + // for real-backend runs and needed for scripted import/export. + PLAYWRIGHT: 'true', + ...profile.env, + ...extraEnv, + }; +} + +/** Pre-seeds a Settings doc so the app starts with known settings (mirrors seedSettings). */ +function seedSettings(dir, patch) { + fs.mkdirSync(dir, { recursive: true }); + const doc = { + _id: 'set_' + Math.random().toString(16).slice(2) + Date.now().toString(16), + type: 'Settings', + parentId: null, + modified: Date.now(), + created: Date.now(), + ...patch, + }; + fs.writeFileSync(path.join(dir, 'insomnia.Settings.db'), JSON.stringify(doc) + '\n'); +} + +function killTree(pid) { + if (!pid) { + return; + } + if (process.platform === 'win32') { + try { + execFileSync('taskkill', ['/pid', String(pid), '/T', '/F']); + } catch {} + return; + } + try { + process.kill(pid, 'SIGKILL'); + } catch {} +} + +// --------------------------------------------------------------------------- +// Element targeting +// --------------------------------------------------------------------------- + +/** + * Turns a plain-JSON target description into a Playwright Locator. + * Accepts a string (treated as CSS) or an object combining one of + * css | role+name | text | testid | label | placeholder | title, + * plus `within` (another target, used as a scope), `hasText`, `nth`/`first`/`last`. + */ +function locate(target, root) { + const base = root || page; + if (!target) { + return base.locator('body'); + } + if (typeof target === 'string') { + return base.locator(target); + } + + const scope = target.within ? locate(target.within, base) : base; + + let loc; + if (target.css) { + loc = scope.locator(target.css); + } else if (target.role) { + loc = scope.getByRole(target.role, { + name: target.name, + exact: target.exact, + ...(target.checked === undefined ? {} : { checked: target.checked }), + }); + } else if (target.testid) { + loc = scope.getByTestId(target.testid); + } else if (target.label) { + loc = scope.getByLabel(target.label, { exact: target.exact }); + } else if (target.placeholder) { + loc = scope.getByPlaceholder(target.placeholder, { exact: target.exact }); + } else if (target.title) { + loc = scope.getByTitle(target.title, { exact: target.exact }); + } else if (target.text) { + loc = scope.getByText(target.text, { exact: target.exact }); + } else { + throw new Error('target needs one of: css, role, testid, label, placeholder, title, text'); + } + + if (target.hasText) { + loc = loc.filter({ hasText: target.hasText }); + } + if (typeof target.nth === 'number') { + loc = loc.nth(target.nth); + } + if (target.first) { + loc = loc.first(); + } + if (target.last) { + loc = loc.last(); + } + return loc; +} + +function need() { + if (!page) { + throw new Error('not launched - run `launch` first'); + } + return page; +} + +// --------------------------------------------------------------------------- +// exec: run arbitrary Playwright code against the live app +// --------------------------------------------------------------------------- + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + +// Lets exec scripts be TypeScript and import the repo's own Page Objects. +register('./ts-loader.mjs', { parentURL: import.meta.url, data: { repo: REPO } }); + +/** + * Loads an exec script and returns its default export. `.ts` works because of + * the loader registered above; `.mjs`/`.js` import natively. + */ +async function loadExecScript(file) { + const candidates = path.isAbsolute(file) ? [file] : [path.resolve(REPO, file), path.resolve(HERE, file)]; + const abs = candidates.find(c => fs.existsSync(c)); + if (!abs) { + throw new Error('exec script not found. Tried: ' + candidates.join(', ')); + } + // Cache-bust so re-running an edited script picks up the edit. Only the entry + // is busted; edits to modules it imports need a driver restart. + const mod = await import(pathToFileURL(abs).href + '?t=' + Date.now()); + const fn = mod.default; + if (typeof fn !== 'function') { + throw new TypeError(file + ' must `export default` an async function taking the context object'); + } + return fn; +} + +/** JSON-safe view of whatever the script returned (Locators etc. are not serialisable). */ +function serialise(v) { + if (v === undefined || v === null) { + return v; + } + try { + JSON.stringify(v); + return v; + } catch { + return String(v); + } +} + +// --------------------------------------------------------------------------- +// Operations +// --------------------------------------------------------------------------- + +const OPS = { + /** Which backends are configured, and where each one's data lives. */ + async profiles() { + const all = loadProfiles(); + return { + current: currentProfile, + default: DEFAULT_PROFILE, + profiles: Object.fromEntries( + Object.entries(all).map(([name, p]) => [ + name, + { + description: p.description || '', + persistent: !p.ephemeral, + dataPath: p.ephemeral ? '(temp, wiped each launch)' : profileDataPath(name), + loggedIn: p.ephemeral ? '(injected fake session)' : fs.existsSync(path.join(profileDataPath(name), 'insomnia.UserSession.db')), + }, + ]), + ), + }; + }, + + /** + * Launch Insomnia. Requires the Vite dev server (`npm run watch:app`) to be + * running - dev-mode Electron loads the renderer from it. + * args: { profile?, dataPath?, fresh?, fixtureDb?, settings?, env?, display? } + * + * `profile` defaults to DEFAULT_PROFILE ('prod'). Real-backend profiles keep a persistent data + * dir under ~/.insomnia-driver/, which is what lets a real login be + * reused instead of repeated - the session lives in that dir's NeDB and the + * login keypair in its localStorage (entry.main.ts points userData there). + */ + async launch(a = {}) { + if (app && page && !page.isClosed()) { + return { already: true, profile: currentProfile, dataPath }; + } + // A half-launched or manually-closed app leaves stale handles behind. + if (app) { + await OPS.quit(); + } + const all = loadProfiles(); + const name = a.profile || DEFAULT_PROFILE; + const profile = all[name]; + if (!profile) { + throw new Error('unknown profile "' + name + '". Known: ' + Object.keys(all).join(', ')); + } + currentProfile = name; + if (!fs.existsSync(path.join(APP_CWD, 'src', 'entry.main.min.js'))) { + throw new Error('src/entry.main.min.js missing - run `npm run watch:app` from the repo root first'); + } + if (!fs.existsSync(path.join(APP_CWD, '.vite-port'))) { + throw new Error('.vite-port missing - the Vite dev server is not running (`npm run watch:app`)'); + } + + if (a.dataPath) { + dataPath = a.dataPath; + } else if (profile.ephemeral) { + dataPath = path.join(os.tmpdir(), 'insomnia-driver', name, String(Date.now())); + } else { + dataPath = profileDataPath(name); + } + + // Only ephemeral profiles get wiped. Wiping a real profile would throw away + // the login it exists to preserve, so `fresh: true` has to be explicit. + const wipe = a.fresh === undefined ? !!profile.ephemeral && !a.dataPath : a.fresh; + if (wipe) { + fs.rmSync(dataPath, { recursive: true, force: true }); + } + + // A data dir belongs to exactly one profile. Launching `mock` against a real + // profile's dir would write its fake INSOMNIA_SESSION over the real login, + // so record the owner and refuse a mismatch. + const marker = path.join(dataPath, '.driver-profile'); + if (fs.existsSync(marker)) { + const owner = fs.readFileSync(marker, 'utf8').trim(); + if (owner !== name && !a.allowProfileMismatch) { + throw new Error( + 'data dir ' + dataPath + ' belongs to profile "' + owner + '", not "' + name + '". ' + + 'Launching a different profile against it can overwrite its saved login. ' + + 'Pass {"allowProfileMismatch":true} to override.', + ); + } + } + fs.mkdirSync(dataPath, { recursive: true }); + fs.writeFileSync(marker, name); + + if (a.fixtureDb) { + const src = path.join(FIXTURES, a.fixtureDb); + if (!fs.existsSync(src)) { + throw new Error('no such fixture db: ' + src); + } + fs.cpSync(src, dataPath, { recursive: true }); + } + // `sidebarFocusForCollections` narrows the sidebar to one collection on click, + // which hides the rest of the tree - off by default here, as in the smoke tests. + // Only seeded on first use, so it can't clobber a real profile's settings. + if (!fs.existsSync(path.join(dataPath, 'insomnia.Settings.db'))) { + seedSettings(dataPath, { sidebarFocusForCollections: false, ...(a.settings || {}) }); + } + + consoleLog = []; + pageErrors = []; + + app = await electron.launch({ + cwd: APP_CWD, + executablePath: ELECTRON_BIN, + // `.` rather than the entry file: entry.main.ts registers the `insomniadev://` + // protocol handler with `process.argv.slice(1)`, rewriting a literal `.` to an + // absolute path. Passing the relative entry path instead would register a + // command line that only works from this cwd, breaking the browser login callback. + args: ['--no-sandbox', '.'], + env: buildEnv({ profile, dataPath, extraEnv: a.env }), + timeout: 120000, + }); + + try { + page = await app.firstWindow({ timeout: 120_000 }); + } catch (e) { + // Most often the single-instance lock: entry.main.ts calls + // requestSingleInstanceLock() whenever deep linking is on (i.e. unless + // PLAYWRIGHT_TEST is set), so a leftover Electron holding the lock for + // this userData dir makes the new process quit on startup. + try { + await app.close(); + } catch {} + app = null; + page = null; + throw new Error( + 'the app exited during launch. Usually a stray Electron still holds the single-instance lock ' + + '(check with `tasklist /fi "imagename eq electron.exe"`, then `taskkill /f /im electron.exe`). Original: ' + + e.message, + ); + } + page.on('console', m => { + consoleLog.push({ type: m.type(), text: m.text() }); + if (consoleLog.length > 500) { + consoleLog.shift(); + } + }); + page.on('pageerror', e => + pageErrors.push({ message: e.message, stack: String(e.stack || '').split('\n').slice(0, 4).join('\n') }), + ); + await page.waitForLoadState(); + const placement = await placeWindow(a.display); + return { profile: name, dataPath, url: page.url(), windows: app.windows().length, placement }; + }, + + /** + * Move the app window to another monitor and maximise it there. + * 'right' (default) | 'left' | 'primary' | | 'none' + */ + async moveWindow(a = {}) { + if (!app) { + throw new Error('not launched'); + } + return placeWindow(a.display); + }, + + /** Who the app thinks it is, and which backend it is pointed at. */ + async whoami() { + const p = need(); + const sess = await OPS.db({ + type: 'UserSession', + fields: ['id', 'accountId', 'email', 'firstName', 'lastName'], + }); + const backend = await p.evaluate(() => ({ + api: window.env?.INSOMNIA_API_URL || 'https://api.insomnia.rest', + website: window.env?.INSOMNIA_APP_WEBSITE_URL || 'https://app.insomnia.rest', + hasLoginKeypair: !!window.localStorage.getItem('insomnia.publicKey'), + })); + // A UserSession doc exists from init() even when logged out; `id` is what fills in. + const user = sess.docs.find(d => d.id) || null; + return { + profile: currentProfile, + repo: REPO, + dataPath, + backend, + loggedIn: !!user, + // The session id is a live credential - report only that one exists. + user: user ? { email: user.email, accountId: user.accountId, name: (user.firstName + ' ' + user.lastName).trim() } : null, + url: p.url(), + }; + }, + + /** + * Step 1 of a real login: hand off to the user's browser. + * + * Clicks the app's own provider button rather than opening the URL directly: + * that is what routes the app to /auth/login-tip -> /auth/authorize, where the + * paste-token box lives, and it exercises the same code path a user would. + * + * CRITICAL: the login keypair is regenerated on every page load + * (auth-session-provider.client.ts calls `sealedBox.keyPair()` at module scope + * and overwrites localStorage). A `reload`, `relaunch` or driver restart + * between `login` and the token exchange invalidates the token. Don't. + */ + async login(a = {}) { + const p = need(); + const existing = await OPS.whoami(); + if (existing.loggedIn && !a.force) { + return { alreadyLoggedIn: true, ...existing, hint: 'pass {"force":true} to log in as someone else' }; + } + + const available = []; + for (const name of ['Google', 'GitHub', 'Email', 'SSO']) { + if (await p.getByRole('button', { name: 'Continue with ' + name }).count()) { + available.push(name); + } + } + if (!available.length) { + throw new Error('no provider buttons on screen - the app is at ' + p.url() + ', not the login screen'); + } + + const publicKey = await p.evaluate(() => window.localStorage.getItem('insomnia.publicKey')); + const website = await p.evaluate(() => window.env?.INSOMNIA_APP_WEBSITE_URL || 'https://app.insomnia.rest'); + const url = new URL(website); + url.pathname = '/app/auth-app/'; + url.searchParams.set('loginKey', encodeURIComponent(publicKey || '')); + url.searchParams.set('source_origin', 'desktop_app'); + + if (!a.provider) { + return { + needProvider: true, + available, + url: url.toString(), + hint: 'call again with {"provider":"GitHub"} to click it, or open the url yourself', + }; + } + if (!available.includes(a.provider)) { + throw new Error('unknown provider "' + a.provider + '". Available: ' + available.join(', ')); + } + + // The app's clientAction calls openInBrowser itself and redirects to /auth/login-tip. + await p.getByRole('button', { name: 'Continue with ' + a.provider }).click(); + await p.waitForURL(/\/auth\/(login-tip|authorize)/, { timeout: 20_000 }).catch(() => {}); + + // Park on the paste-token screen so the fallback is ready if the deep link + // never fires. Client-side navigation - no reload, so the keypair survives. + const alternatives = p.getByRole('button', { name: 'Show login alternatives' }); + if (await alternatives.count()) { + await alternatives.click(); + } + + return { + openedInBrowser: true, + provider: a.provider, + url: url.toString(), + appUrl: p.url(), + next: 'finish signing in in the browser, then run `waitForSession`. If the app never returns, copy the token the site shows and run `submitCode`.', + }; + }, + + /** Step 2 fallback: paste the token the website shows when the deep link doesn't fire. */ + async submitCode(a) { + const p = need(); + if (!a.code) { + throw new Error('submitCode needs `code`'); + } + const input = p.locator('input[name="code"]'); + if (!(await input.count())) { + const alternatives = p.getByRole('button', { name: 'Show login alternatives' }); + if (await alternatives.count()) { + await alternatives.click(); + } + } + // Never `goto` here: a page load regenerates the keypair, and the token the + // site issued can no longer be opened. + await input.fill(String(a.code).trim(), { timeout: 10_000 }); + await p.getByRole('button', { name: 'Log in' }).click(); + return OPS.waitForSession({ timeout: a.timeout || 60_000 }); + }, + + /** Polls until a session lands in the data dir (or times out). */ + async waitForSession(a = {}) { + const deadline = Date.now() + (a.timeout || 300_000); + for (;;) { + const r = await OPS.db({ type: 'UserSession', fields: ['id', 'email', 'accountId'] }); + const user = r.docs[0]; + if (user && user.id) { + return { loggedIn: true, user: { email: user.email, accountId: user.accountId } }; + } + if (Date.now() > deadline) { + return { loggedIn: false, timedOut: true, url: page ? page.url() : null }; + } + await new Promise(r2 => setTimeout(r2, 2000)); + } + }, + + async quit() { + if (!app) { + return { closed: false }; + } + const pid = app.process() && app.process().pid; + try { + await Promise.race([ + app.close(), + new Promise((_, r) => setTimeout(() => r(new Error('close timeout')), 5000)), + ]); + } catch { + killTree(pid); + } + app = null; + page = null; + return { closed: true }; + }, + + /** + * Reload the renderer only. The Vite dev server serves the renderer, so after + * editing anything under src/ui this picks the change up in ~2s - far cheaper + * than a relaunch. Changes to src/main/** need `relaunch` instead, because the + * main process bundle is only rebuilt by the esbuild entrypoints watcher. + */ + async reload(a = {}) { + const p = need(); + await p.reload({ timeout: a.timeout || 60000 }); + await p.waitForLoadState(); + return { ok: true, url: p.url() }; + }, + + /** Close and relaunch against the same data dir - for persistence / cold-start checks. */ + async relaunch(a = {}) { + const keep = dataPath; + // Carrying the profile over matters: relaunching a real profile under + // `mock` would inject the fake INSOMNIA_SESSION into its data dir and + // overwrite the real login. + const keepProfile = currentProfile; + await OPS.quit(); + return OPS.launch({ profile: keepProfile, ...a, dataPath: keep, fresh: false }); + }, + + async status() { + return { + launched: !!app, + repo: REPO, + profile: currentProfile, + dataPath, + url: page ? page.url() : null, + windows: app ? app.windows().map(w => w.url()) : [], + pageErrors: pageErrors.length, + shotDir: SHOT_DIR, + }; + }, + + /** + * ARIA snapshot of the UI as YAML - the cheap way to "see" the screen. + * Prefer this over screenshots when deciding what to click next. + */ + async snapshot(a = {}) { + const yaml = await locate(a.target, need()).ariaSnapshot({ timeout: a.timeout || 10000 }); + return { snapshot: yaml }; + }, + + async screenshot(a = {}) { + const p = need(); + const file = path.join(SHOT_DIR, (a.name || 'shot-' + Date.now()) + '.png'); + if (a.target) { + await locate(a.target, p).screenshot({ path: file }); + } else { + await p.screenshot({ path: file, fullPage: !!a.fullPage }); + } + return { path: file }; + }, + + async click(a) { + await locate(a.target, need()).click({ + timeout: a.timeout || 10000, + force: !!a.force, + button: a.button || 'left', + clickCount: a.clickCount || 1, + }); + return { ok: true }; + }, + + async hover(a) { + await locate(a.target, need()).hover({ timeout: a.timeout || 10000 }); + return { ok: true }; + }, + + async fill(a) { + await locate(a.target, need()).fill(String(a.value), { timeout: a.timeout || 10000 }); + return { ok: true }; + }, + + /** For CodeMirror / contentEditable inputs that `fill` cannot drive. */ + async type(a) { + const p = need(); + if (a.target) { + await locate(a.target, p).click({ timeout: a.timeout || 10000 }); + } + await p.keyboard.type(String(a.text), { delay: a.delay === undefined ? 20 : a.delay }); + return { ok: true }; + }, + + async press(a) { + const p = need(); + if (a.target) { + await locate(a.target, p).press(a.key, { timeout: a.timeout || 10000 }); + } else { + await p.keyboard.press(a.key); + } + return { ok: true }; + }, + + async text(a = {}) { + const loc = locate(a.target, need()); + const n = await loc.count(); + if (n === 0) { + return { count: 0, text: null }; + } + return { count: n, text: await loc.first().innerText({ timeout: a.timeout || 10000 }) }; + }, + + async count(a) { + return { count: await locate(a.target, need()).count() }; + }, + + /** Wait for a target to reach a state: visible (default) | hidden | attached | detached. */ + async wait(a) { + await locate(a.target, need()).waitFor({ state: a.state || 'visible', timeout: a.timeout || 15000 }); + return { ok: true }; + }, + + async sleep(a = {}) { + await new Promise(r => setTimeout(r, a.ms === undefined ? 500 : a.ms)); + return { ok: true }; + }, + + /** Evaluate an expression in the renderer. */ + async eval(a) { + const body = a.expr.includes('return') ? a.expr : 'return (' + a.expr + ')'; + return { result: await need().evaluate('(() => { ' + body + ' })()') }; + }, + + /** + * Evaluate in the Electron main process. + * + * `expr` must be a **function expression** taking the electron module - it is + * called as `()(electron)`. There is no `require` in there, and a bare + * statement block is a syntax error; both are easy to trip over, so they get + * explicit errors below. + * + * mainEval '{"expr":"({ app }) => app.getPath(\'userData\')"}' + * mainEval '{"expr":"async ({ session }) => (await session.defaultSession.getCacheSize())"}' + */ + async mainEval(a) { + if (!app) { + throw new Error('not launched'); + } + const expr = String(a.expr || '').trim(); + if (!/^(async\s*)?(\(|function\b)/.test(expr) && !expr.includes('=>')) { + throw new Error( + 'mainEval takes a function expression, not statements. Got: ' + expr.slice(0, 60) + '\n' + + 'Use e.g. ({ app, session }) => app.getPath("userData") - the electron module is the argument.', + ); + } + let fn; + try { + fn = new Function('electron', 'return (' + expr + ')(electron)'); + } catch (e) { + throw new SyntaxError( + 'mainEval could not compile the expression (' + e.message + '). It must be a single function ' + + 'expression, e.g. ({ app }) => app.getVersion()', + ); + } + try { + return { result: await app.evaluate(fn) }; + } catch (e) { + if (/require is not defined/.test(e.message)) { + throw new Error( + 'No `require` in the main-process evaluation context. Electron is handed to your function as ' + + 'its argument instead: ({ session, app, BrowserWindow }) => ... . Node builtins are not ' + + 'reachable from here; use `exec` with a script if you need them.', + ); + } + throw e; + } + }, + + /** + * Record Insomnia's outbound HTTP, and optionally slow it down. + * + * Why not `page.route` / `page.on('request')`: Insomnia takes over the http + * and https schemes itself (main/api.protocol.ts:139,160) and re-issues every + * request from the main process with `net.fetch`. The renderer request the + * page sees is a stub that never reaches the network, which is why renderer + * interception records traffic only intermittently and why delays injected + * with `route.continue()`, `session.enableNetworkEmulation()` or CDP + * `Network.emulateNetworkConditions` have no measurable effect. All three + * were tried and measured; see scripts/probe-throttle.ts. + * + * `session.defaultSession.webRequest` is the layer that does see it, so both + * recording and throttling live here, behind ONE set of listeners - Electron + * allows a single listener per event per session, so separate installs would + * silently clobber each other. + * + * Each logical API call appears TWICE: once as the renderer's original + * (resourceType `xhr`) and once as main's re-issue (`other`). That is the + * interception, not a retry. + * + * Neither op sees requests the *user* sends from the app - those go through + * node-libcurl, outside Chromium. Use the echo server log or `db` for those. + */ + async netlog(a = {}) { + await ensureNetHooks(); + const action = a.action || 'start'; + + if (action === 'start') { + await app.evaluate( + (_electron, opts) => { + const n = globalThis.__driverNet; + n.recording = true; + n.pattern = opts.pattern; + n.all = opts.all; + n.log = []; + n.seq = 0; + }, + { pattern: a.pattern || null, all: !!a.all }, + ); + return { + recording: true, + pattern: a.pattern || '(all http/https)', + hint: 'reproduce the flow, then netlog {"action":"read"}', + }; + } + + if (action === 'stop') { + await app.evaluate(() => { + globalThis.__driverNet.recording = false; + }); + return { recording: false }; + } + + if (action === 'clear') { + await app.evaluate(() => { + globalThis.__driverNet.log = []; + }); + return { cleared: true }; + } + + if (action === 'read') { + const state = await app.evaluate(() => ({ + recording: globalThis.__driverNet.recording, + log: globalThis.__driverNet.log, + })); + if (!state.recording && state.log.length === 0) { + throw new Error('netlog is not recording - run netlog {"action":"start"} before the flow you want to capture'); + } + const matched = a.pattern ? state.log.filter(r => r.url.includes(a.pattern)) : state.log; + return { total: state.log.length, matched: matched.length, requests: matched.slice(-(a.limit || 50)) }; + } + + throw new Error('netlog action must be start | read | stop | clear, got "' + action + '"'); + }, + + /** + * Add latency to make a timing-dependent race reproducible, by holding the + * webRequest callback before the request goes out. See `netlog` for why the + * obvious APIs (enableNetworkEmulation, CDP, route.continue) do nothing here. + * + * throttle '{"latency":1500}' every http(s) request + * throttle '{"latency":1500,"pattern":"konghq"}' only matching URLs + * throttle '{"off":true}' back to normal + * + * NOTE: the delay lands on each webRequest event, and an Insomnia API call + * produces two of them (see `netlog`), so end-to-end latency is roughly + * double what you ask for. Dial it in against a `netlog` reading rather than + * assuming the number. + */ + async throttle(a = {}) { + await ensureNetHooks(); + const latency = a.off ? 0 : a.latency || 0; + const pattern = a.off ? null : a.pattern || null; + await app.evaluate( + (_electron, opts) => { + const n = globalThis.__driverNet; + n.latencyMs = opts.latency; + n.latencyPattern = opts.pattern; + }, + { latency, pattern }, + ); + return latency > 0 + ? { throttling: true, latencyPerEvent: latency, pattern: pattern || '(all http/https)' } + : { throttling: false }; + }, + + + /** + * Narrate a step on top of the running app, for a human watching the window. + * + * Driving the UI at machine speed is unreadable - a viewer sees things happen + * with no idea what or why. This paints a caption bar and rings the element + * that is about to be used, so the explanation sits next to the thing it + * describes instead of in a terminal the viewer isn't looking at. + * + * Returns immediately; it does NOT wait for the viewer. The pause has to be a + * conversation turn, otherwise the driver is blocked and can't answer + * questions mid-demo. + * + * demo '{"text":"Open the environment editor","step":2,"of":6, + * "target":{"role":"button","name":"Manage Environments"}}' + * demo '{"clear":true}' + * + * `position: "top"` moves the caption off Insomnia's own status bar, for when + * the status bar is the thing being demonstrated. + */ + async demo(a = {}) { + const p = need(); + if (a.clear) { + await p.evaluate(() => document.getElementById('__insomnia_driver_demo__')?.remove()); + return { cleared: true }; + } + if (!a.text) { + throw new Error('demo needs `text` (what this step does and why), or {"clear":true}'); + } + + // Measure in Node and draw by coordinates, rather than styling the app's own + // element: no mutation of the UI under test, and it works for anything the + // target resolver can find. + let box = null; + if (a.target) { + try { + box = await locate(a.target, p).first().boundingBox({ timeout: a.timeout || 5000 }); + } catch (e) { + // Degrading to a caption-only step hides a typo'd target until someone + // notices the missing ring mid-demo, so say so unless asked not to. + if (a.requireTarget !== false) { + throw new Error( + 'demo could not resolve its highlight target (' + e.message.split('\n')[0] + '). ' + + 'Fix the target, or pass {"requireTarget":false} if this step intentionally narrates ' + + 'something not yet on screen.', + ); + } + } + } + + await p.evaluate( + ({ text, step, of, box: b, note, position }) => { + const ID = '__insomnia_driver_demo__'; + document.getElementById(ID)?.remove(); + const root = document.createElement('div'); + root.id = ID; + root.style.cssText = + 'position:fixed;inset:0;z-index:2147483647;pointer-events:none;font-family:system-ui,sans-serif'; + + if (b) { + const ring = document.createElement('div'); + ring.style.cssText = + 'position:fixed;border:3px solid #ff4d6d;border-radius:6px;box-shadow:0 0 0 4px rgba(255,77,109,.28);' + + 'transition:all .25s ease;' + + `left:${b.x - 4}px;top:${b.y - 4}px;width:${b.width + 8}px;height:${b.height + 8}px`; + root.appendChild(ring); + } + + const bar = document.createElement('div'); + const atTop = position === 'top'; + bar.style.cssText = + 'position:fixed;left:0;right:0;padding:14px 20px;background:rgba(17,17,24,.94);' + + 'color:#fff;font-size:15px;line-height:1.5;display:flex;gap:14px;align-items:baseline;' + + (atTop ? 'top:0;border-bottom:3px solid #ff4d6d' : 'bottom:0;border-top:3px solid #ff4d6d'); + if (step) { + const badge = document.createElement('span'); + badge.textContent = of ? `${step} / ${of}` : String(step); + badge.style.cssText = + 'flex:none;background:#ff4d6d;color:#fff;border-radius:999px;padding:2px 12px;font-size:13px;font-weight:600'; + bar.appendChild(badge); + } + const body = document.createElement('div'); + body.textContent = text; + bar.appendChild(body); + if (note) { + const hint = document.createElement('div'); + hint.textContent = note; + hint.style.cssText = 'margin-left:auto;flex:none;opacity:.6;font-size:13px'; + bar.appendChild(hint); + } + root.appendChild(bar); + document.body.appendChild(root); + }, + { text: a.text, step: a.step, of: a.of, box, note: a.note, position: a.position || 'bottom' }, + ); + + if (a.hold) { + await new Promise(r => setTimeout(r, a.hold)); + } + return { shown: true, highlighted: !!box }; + }, + + /** Put text on the clipboard - backs the import-from-clipboard path. */ + async setClipboard(a) { + if (!app) { + throw new Error('not launched'); + } + await app.evaluate(({ clipboard }, t) => clipboard.writeText(t), String(a.text)); + return { ok: true }; + }, + + /** Import a fixture from packages/insomnia-smoke-test/fixtures through the real import UI. */ + async importFixture(a) { + const p = need(); + await OPS.setClipboard({ text: fs.readFileSync(path.join(FIXTURES, a.file), 'utf8') }); + await p.getByLabel('Import').click({ timeout: 15000 }); + await p.locator('[data-test-id="import-from-clipboard"]').click(); + await p.getByRole('button', { name: 'Scan' }).click(); + await p.getByRole('dialog').getByRole('button', { name: 'Import' }).click(); + await p.getByRole('dialog').waitFor({ state: 'hidden' }); + await p + .getByTestId('workspace-breadcrumb-level-0') + .waitFor({ state: 'visible', timeout: 5000 }) + .catch(() => {}); + return { ok: true }; + }, + + /** Renderer console messages captured since launch. */ + async logs(a = {}) { + const items = a.type ? consoleLog.filter(l => l.type === a.type) : consoleLog; + return { logs: items.slice(-(a.limit || 50)) }; + }, + + async errors() { + return { errors: pageErrors }; + }, + + /** Read a NeDB collection out of the live data dir - verify what the UI actually persisted. */ + async db(a) { + if (!dataPath) { + throw new Error('not launched'); + } + const file = path.join(dataPath, 'insomnia.' + a.type + '.db'); + if (!fs.existsSync(file)) { + return { docs: [], missing: file }; + } + const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)); + // NeDB appends; a later line supersedes an earlier one with the same _id. + const byId = new Map(); + for (const d of lines) { + if (d.$$deleted) { + byId.delete(d._id); + } else { + byId.set(d._id, { ...byId.get(d._id), ...d }); + } + } + let out = [...byId.values()]; + if (a.where) { + out = out.filter(d => Object.entries(a.where).every(([k, v]) => d[k] === v)); + } + if (a.fields) { + out = out.map(d => Object.fromEntries(a.fields.map(f => [f, d[f]]))); + } + return { count: out.length, docs: out.slice(0, a.limit || 50) }; + }, + + /** + * Escape hatch: run arbitrary Playwright code against the live app, so the + * whole Playwright API is reachable - not just the ops in this file. + * + * { file: 'path/to/script.ts' } preferred. Zero shell/JSON escaping, and a + * .ts script can import the repo's Page Objects. + * Must `export default async ctx => { ... }`. + * { code: 'await page.click(...)' } one-liners. Body of an async function; + * the context is pre-destructured into scope. + * + * ctx = { page, app, expect, ops, locate, log, dataPath, REPO, APP_CWD, FIXTURES } + * `log(...)` collects values into the response; the return value comes back as + * `result` when it is JSON-serialisable, else as its string form. + */ + async exec(a = {}) { + need(); + const logs = []; + const ctx = { + page, + app, + expect, + ops: OPS, + locate: t => locate(t, page), + log: (...xs) => logs.push(xs.map(x => (typeof x === 'string' ? x : JSON.stringify(serialise(x)))).join(' ')), + dataPath, + REPO, + APP_CWD, + FIXTURES, + }; + + let fn; + if (a.file) { + fn = await loadExecScript(a.file); + } else if (a.code) { + const preamble = 'const { ' + Object.keys(ctx).join(', ') + ' } = ctx;\n'; + fn = new AsyncFunction('ctx', preamble + a.code); + } else { + throw new Error('exec needs `file` or `code`'); + } + + try { + return { logs, result: serialise(await fn(ctx)) }; + } catch (e) { + // Attach the logs so a failing script still reports how far it got. + e.execLogs = logs; + throw e; + } + }, + + /** + * Leave a remark for whoever maintains this driver. Use it the moment the + * driver costs you time: a missing op, a selector that needed three tries, a + * gotcha the SKILL.md should have warned about. Failures are logged on their + * own; this is for the part only you know - what you were *trying* to do. + */ + async note(a) { + if (!a.text) { + throw new Error('note needs `text`'); + } + appendFeedback({ kind: 'note', text: String(a.text), ...(a.op ? { op: a.op } : {}) }); + return { logged: true, file: FEEDBACK_LOG }; + }, + + /** Read the friction log back: recent entries plus a rollup of repeat offenders. */ + async feedback(a = {}) { + if (!fs.existsSync(FEEDBACK_LOG)) { + return { entries: [], topErrors: [], file: FEEDBACK_LOG }; + } + let entries = fs + .readFileSync(FEEDBACK_LOG, 'utf8') + .split('\n') + .filter(Boolean) + .map(l => { + try { + return JSON.parse(l); + } catch { + return null; + } + }) + .filter(Boolean); + + if (a.since) { + entries = entries.filter(e => e.ts >= a.since); + } + if (a.kind) { + entries = entries.filter(e => e.kind === a.kind); + } + + const counts = new Map(); + for (const e of entries) { + if (e.kind !== 'error') { + continue; + } + const key = e.op + ': ' + e.error; + counts.set(key, (counts.get(key) || 0) + 1); + } + const topErrors = [...counts.entries()] + .sort((x, y) => y[1] - x[1]) + .slice(0, 10) + .map(([what, count]) => ({ count, what })); + + return { + file: FEEDBACK_LOG, + total: entries.length, + notes: entries.filter(e => e.kind === 'note').length, + topErrors, + entries: entries.slice(-(a.limit || 30)), + }; + }, + + async help() { + return { ops: Object.keys(OPS) }; + }, +}; + +// --------------------------------------------------------------------------- +// HTTP control channel +// --------------------------------------------------------------------------- + +const server = http.createServer((req, res) => { + let body = ''; + req.on('data', c => (body += c)); + req.on('end', async () => { + let payload = {}; + try { + payload = body ? JSON.parse(body) : {}; + } catch { + payload = {}; + } + const op = (req.url || '').replace(/^\//, '').split('?')[0]; + const fn = OPS[op]; + res.setHeader('content-type', 'application/json'); + if (!fn) { + res.statusCode = 404; + res.end(JSON.stringify({ ok: false, error: 'unknown op "' + op + '"', ops: Object.keys(OPS) })); + return; + } + try { + const result = await fn(payload); + res.end(JSON.stringify({ ok: true, ...result })); + } catch (e) { + if (!NOT_FRICTION.has(op) && !payload.quiet) { + // An error signature alone can't be triaged later - "click timed out" is + // equally a bad selector and an app that never got to that screen. Grab + // what was on screen, best-effort and time-boxed. + appendFeedback({ + kind: 'error', + op, + args: payload, + error: errorSignature(e.message), + ...(await captureContext()), + }); + } + res.statusCode = 500; + res.end( + JSON.stringify({ + ok: false, + error: e.message, + ...(e.execLogs ? { logs: e.execLogs } : {}), + stack: String(e.stack || '').split('\n').slice(0, 8).join('\n'), + }), + ); + } + }); +}); + +// One driver owns the port, and there is usually one already running in the +// worktree where this tooling is maintained. Say so plainly - the bare +// EADDRINUSE stack is the first thing a new session trips over. +server.on('error', err => { + if (err.code === 'EADDRINUSE') { + console.error( + 'Port ' + PORT + ' is already taken - a driver is running, most likely for another worktree.\n' + + 'Either reuse it (run drive.mjs and check its `repo`), or start this one on its own port:\n' + + ' DRIVER_PORT=9801 node ' + path.join(HERE, 'driver.mjs') + '\n' + + 'and pass DRIVER_PORT=9801 to drive.mjs too. A second concurrent app also needs its own\n' + + 'dataPath, because Electron\'s single-instance lock is keyed on it.', + ); + process.exit(1); + } + throw err; +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log('[driver] listening on http://127.0.0.1:' + PORT); + console.log('[driver] repo=' + REPO); + console.log('[driver] shots=' + SHOT_DIR); +}); + +process.on('SIGINT', async () => { + await OPS.quit(); + process.exit(0); +}); +process.on('SIGTERM', async () => { + await OPS.quit(); + process.exit(0); +}); diff --git a/.claude/skills/run-insomnia/profiles.example.json b/.claude/skills/run-insomnia/profiles.example.json new file mode 100644 index 0000000000..bb9fc4a187 --- /dev/null +++ b/.claude/skills/run-insomnia/profiles.example.json @@ -0,0 +1,30 @@ +{ + "_comment": [ + "Copy to profiles.json and fill in your team's backend URLs. profiles.json is", + "gitignored on purpose: this repo is public, and internal hostnames should not", + "end up in its history. Ask a teammate for the real values.", + "", + "Mirror whatever env vars you normally pass to `npm run dev` for that stack.", + "Anything you leave out falls back to the PRODUCTION default in", + "packages/insomnia/src/common/constants.ts - so omitting KONNECT_API_URL", + "points that one service at prod while the rest go to your stack.", + "The built-in `mock` and `prod` profiles need no configuration." + ], + "dev": { + "description": "internal dev stack", + "env": { + "INSOMNIA_API_URL": "https://api.dev.example.com", + "INSOMNIA_APP_WEBSITE_URL": "https://app.dev.example.com", + "INSOMNIA_AI_URL": "https://ai.dev.example.com", + "INSOMNIA_MOCK_API_URL": "https://mock.dev.example.com", + "KONNECT_API_URL": "https://global.api.example.com" + } + }, + "staging": { + "description": "staging stack", + "env": { + "INSOMNIA_API_URL": "https://api.staging.example.com", + "INSOMNIA_APP_WEBSITE_URL": "https://app.staging.example.com" + } + } +} diff --git a/.claude/skills/run-insomnia/scripts/example-flow.ts b/.claude/skills/run-insomnia/scripts/example-flow.ts new file mode 100644 index 0000000000..e6b20faa14 --- /dev/null +++ b/.claude/skills/run-insomnia/scripts/example-flow.ts @@ -0,0 +1,55 @@ +/** + * Example exec script — the escape hatch in its intended form. + * + * node .claude/skills/run-insomnia/drive.mjs exec .claude/skills/run-insomnia/scripts/example-flow.ts + * + * Because the driver bundles `.ts` scripts with esbuild before importing them, + * this can pull in the smoke suite's maintained Page Objects instead of + * re-deriving selectors, and use the full Playwright API directly. + * + * Everything below runs in ONE round trip. The same flow expressed as + * individual `drive.mjs` ops would be ~10 separate process spawns. + */ +import type { ElectronApplication, Page } from '@playwright/test'; + +import { InsomniaApp } from '@repo/packages/insomnia-smoke-test/playwright/pages'; + +interface Ctx { + page: Page; + app: ElectronApplication; + expect: typeof import('@playwright/test').expect; + log: (...xs: unknown[]) => void; + ops: Record) => Promise>; +} + +export default async ({ page, app, expect, log, ops }: Ctx) => { + const insomnia = new InsomniaApp(page, app); + + await insomnia.projectPage.importFixture('smoke-test-collection.yaml'); + log('imported smoke-test-collection.yaml'); + + await insomnia.navigationSidebar.clickRequestOrFolder('send JSON request'); + + const requestPane = page.getByTestId('request-pane'); + const statusTag = page.getByTestId('response-pane').getByTestId('response-status-tag'); + + await requestPane.getByRole('button', { name: 'Send' }).click(); + await expect(statusTag).toContainText('200 OK'); + log('sent "send JSON request" ->', await statusTag.innerText()); + + // Native Playwright API the op vocabulary never exposed: response interception. + const timings = await page.evaluate(() => performance.now()); + log('renderer uptime ms:', Math.round(timings)); + + await ops.screenshot({ name: 'example-flow' }); + + // Cross-check the UI against what actually landed in NeDB. + const responses = (await ops.db({ type: 'Response', fields: ['statusCode', 'url'] })) as { + docs: { statusCode: number; url: string }[]; + }; + + return { + requestCount: await page.getByTestId('request-pane').count(), + persistedResponses: responses.docs, + }; +}; diff --git a/.claude/skills/run-insomnia/scripts/goto-login.ts b/.claude/skills/run-insomnia/scripts/goto-login.ts new file mode 100644 index 0000000000..61718d3a56 --- /dev/null +++ b/.claude/skills/run-insomnia/scripts/goto-login.ts @@ -0,0 +1,39 @@ +/** + * Walks a freshly launched app to the provider-picker screen, whichever entry + * point it happened to land on. + * + * node .claude/skills/run-insomnia/drive.mjs exec .claude/skills/run-insomnia/scripts/goto-login.ts + * + * A first launch of a real profile lands on /onboarding; a second one lands in + * the Scratch Pad. Both reach the login screen, just by different clicks. + */ +import type { Page } from '@playwright/test'; + +interface Ctx { + page: Page; + log: (...xs: unknown[]) => void; +} + +export default async ({ page, log }: Ctx) => { + for (let step = 0; step < 6; step++) { + if (await page.getByRole('button', { name: 'Continue with Email' }).count()) { + const providers = await page.getByRole('button', { name: /^Continue with / }).allInnerTexts(); + return { at: 'login', url: page.url(), providers: providers.map(p => p.trim()) }; + } + + const login = page.getByRole('link', { name: 'Login' }); + const onboardingNext = page.getByRole('link', { name: 'Continue' }); + + if (await login.count()) { + log('clicking Login at', page.url()); + await login.first().click(); + } else if (await onboardingNext.count()) { + log('advancing onboarding at', page.url()); + await onboardingNext.first().click(); + } else { + return { at: 'stuck', url: page.url(), hint: 'no Login link or onboarding Continue on screen' }; + } + await page.waitForTimeout(2500); + } + return { at: 'gave-up', url: page.url() }; +}; diff --git a/.claude/skills/run-insomnia/scripts/probe-throttle.ts b/.claude/skills/run-insomnia/scripts/probe-throttle.ts new file mode 100644 index 0000000000..5c14c1fc0c --- /dev/null +++ b/.claude/skills/run-insomnia/scripts/probe-throttle.ts @@ -0,0 +1,87 @@ +/** + * Throwaway probe: does anything actually slow Insomnia's network down? + * + * Times an identical renderer-side fetch under three conditions, so a no-op is + * distinguishable from "worked but I measured the wrong thing". + */ +import type { ElectronApplication, Page } from '@playwright/test'; + +interface Ctx { + page: Page; + app: ElectronApplication; + log: (...xs: unknown[]) => void; +} + +/** Any endpoint the app itself would call. Taken from whichever profile is + * running, so this probe works on mock as well as a real backend. */ +const PROBE_PATH = '/v1/billing/current-plan'; + +export default async ({ page, app, log }: Ctx) => { + const apiBase = await page.evaluate(() => window.env?.INSOMNIA_API_URL || 'https://api.insomnia.rest'); + const urlUnderTest = apiBase + PROBE_PATH; + log('probing', urlUnderTest); + + const time = async (label: string) => { + const samples: number[] = []; + for (let i = 0; i < 3; i++) { + const ms = await page.evaluate(async url => { + const t0 = performance.now(); + try { + await fetch(url, { cache: 'no-store' }); + } catch { + /* status doesn't matter, timing does */ + } + return Math.round(performance.now() - t0); + }, `${urlUnderTest}?cachebust=${Date.now()}_${i}`); + samples.push(ms); + } + log(label.padEnd(34), samples.join('ms, ') + 'ms'); + return samples; + }; + + await time('baseline'); + + // 1. Electron Session API + await app.evaluate(({ session }) => + session.defaultSession.enableNetworkEmulation({ latency: 1500, downloadThroughput: -1, uploadThroughput: -1 }), + ); + await time('session.enableNetworkEmulation'); + await app.evaluate(({ session }) => session.defaultSession.disableNetworkEmulation()); + + // 2. CDP via the renderer's own debugger. Playwright is already attached over + // CDP, so this may well refuse — that result is itself worth knowing. + let cdp = 'not attempted'; + try { + cdp = await app.evaluate(async ({ BrowserWindow }) => { + const wc = BrowserWindow.getAllWindows()[0].webContents; + try { + wc.debugger.attach('1.3'); + } catch (e) { + return 'attach failed: ' + (e as Error).message; + } + await wc.debugger.sendCommand('Network.enable'); + await wc.debugger.sendCommand('Network.emulateNetworkConditions', { + offline: false, + latency: 1500, + downloadThroughput: -1, + uploadThroughput: -1, + }); + return 'attached'; + }); + } catch (e) { + cdp = 'threw: ' + (e as Error).message; + } + log('cdp attach ->', cdp); + + if (cdp === 'attached') { + await time('CDP emulateNetworkConditions'); + await app.evaluate(({ BrowserWindow }) => { + const wc = BrowserWindow.getAllWindows()[0].webContents; + try { + wc.debugger.detach(); + } catch {} + }); + } + + return { cdp }; +}; diff --git a/.claude/skills/run-insomnia/scripts/rehearse-request-flow.ts b/.claude/skills/run-insomnia/scripts/rehearse-request-flow.ts new file mode 100644 index 0000000000..caac35a904 --- /dev/null +++ b/.claude/skills/run-insomnia/scripts/rehearse-request-flow.ts @@ -0,0 +1,55 @@ +/** + * Rehearsal for the "send a request, read the response" demo. + * + * Runs the whole script at full speed to prove every step resolves before a + * human is asked to watch it. Prints what each demo step will need, so a broken + * selector shows up here rather than mid-demo. + */ +import type { ElectronApplication, Page } from '@playwright/test'; + +import { InsomniaApp } from '@repo/packages/insomnia-smoke-test/playwright/pages'; + +interface Ctx { + page: Page; + app: ElectronApplication; + expect: typeof import('@playwright/test').expect; + log: (...xs: unknown[]) => void; +} + +export default async ({ page, app, expect, log }: Ctx) => { + const insomnia = new InsomniaApp(page, app); + + await insomnia.projectPage.importFixture('smoke-test-collection.yaml'); + log('step 1 ok - fixture imported'); + + await insomnia.navigationSidebar.clickRequestOrFolder('send JSON request'); + const urlBar = page.getByTestId('request-pane').getByTestId('OneLineEditor').first(); + await expect(urlBar).toContainText('127.0.0.1:4010/pets/1'); + log('step 2 ok - request selected, url is', (await urlBar.innerText()).trim()); + + const sendButton = page.getByTestId('request-pane').getByRole('button', { name: 'Send' }); + await expect(sendButton).toBeVisible(); + await sendButton.click(); + log('step 3 ok - Send clicked'); + + const statusTag = page.getByTestId('response-pane').getByTestId('response-status-tag'); + await expect(statusTag).toContainText('200 OK'); + log('step 4 ok - status is', (await statusTag.innerText()).trim()); + + const preview = page.getByRole('button', { name: 'Preview' }); + await expect(preview).toBeVisible(); + await preview.click(); + await page.getByRole('menuitem', { name: 'Raw Data' }).click(); + const body = page.locator('[data-testid="response-pane"] >> [data-testid="CodeEditor"]:visible'); + await expect(body).toContainText('{"id":"1"}'); + log('step 5 ok - raw body is', (await body.innerText()).trim()); + + const consoleTab = page.getByTestId('response-pane').getByRole('tab', { name: 'Console' }); + await expect(consoleTab).toBeVisible(); + await consoleTab.click(); + const timeline = page.getByTestId('response-pane'); + await expect(timeline).toContainText('Preparing request to'); + log('step 6 ok - console/timeline shows the raw exchange'); + + return { rehearsed: true, steps: 6 }; +}; diff --git a/.claude/skills/run-insomnia/ts-loader.mjs b/.claude/skills/run-insomnia/ts-loader.mjs new file mode 100644 index 0000000000..17bbf8c23f --- /dev/null +++ b/.claude/skills/run-insomnia/ts-loader.mjs @@ -0,0 +1,101 @@ +/** + * ESM loader hooks that let `exec` scripts import the repo's TypeScript directly. + * + * Two things Node can't do on its own here: + * 1. The smoke-test Page Objects use extensionless relative imports + * (`./insomnia-app`, `../launch`) - ESM requires explicit extensions. + * 2. Node's built-in type stripping doesn't cover every file, and we want + * esbuild's transform for consistency with the rest of the repo. + * + * Deliberately transpiles per module rather than bundling: bundling collapses + * every source file into one, so `__dirname` would resolve to the bundle's + * location and `paths.ts`'s fixture lookups (`__dirname/../fixtures`) would break. + */ +import fs from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const TS_EXT = /\.(ts|tsx|mts|cts)$/; + +// Hook threads get no module-scope state from the driver, so the repo path +// arrives through register()'s `data`. esbuild then comes from the repo's +// node_modules - this file lives outside any package and cannot import it. +let REPO = null; +let esbuild = null; + +export async function initialize(data) { + REPO = data.repo; + esbuild = createRequire(path.join(REPO, 'package.json'))('esbuild'); +} + +/** `?t=` cache-busting queries would otherwise break fileURLToPath. */ +function toPath(url) { + const u = new URL(url); + u.search = ''; + u.hash = ''; + return fileURLToPath(u); +} + +const CANDIDATE_SUFFIXES = ['.ts', '.tsx', '.mts', '.js', '.mjs', '/index.ts', '/index.tsx', '/index.js']; + +export async function resolve(specifier, context, nextResolve) { + // `@repo/` addresses the driven checkout, so an exec script can import + // repo sources without a relative path that depends on where it is stored. + if (specifier.startsWith('@repo/')) { + const base = path.join(REPO, specifier.slice('@repo/'.length)); + for (const suffix of ['', ...CANDIDATE_SUFFIXES]) { + const candidate = base + suffix; + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return { url: pathToFileURL(candidate).href, format: 'module', shortCircuit: true }; + } + } + throw new Error('cannot resolve ' + specifier + ' under ' + REPO); + } + + const isRelative = specifier.startsWith('./') || specifier.startsWith('../'); + if (isRelative && !path.extname(specifier) && context.parentURL) { + const base = path.resolve(path.dirname(toPath(context.parentURL)), specifier); + for (const suffix of CANDIDATE_SUFFIXES) { + const candidate = base + suffix; + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return { url: pathToFileURL(candidate).href, format: 'module', shortCircuit: true }; + } + } + } + return nextResolve(specifier, context); +} + +/** + * The smoke-test sources are CJS-flavoured TypeScript and use `__dirname`, + * `__filename` and `require`, none of which exist in an ES module. Injecting the + * shims per module is what keeps `__dirname` pointing at each file's own + * directory - the reason this loader transpiles instead of bundling. + */ +const CJS_GLOBALS = /\b(__dirname|__filename|require)\b/; +const CJS_SHIM = [ + "import { createRequire as __createRequire } from 'node:module';", + "import { fileURLToPath as __fileURLToPath } from 'node:url';", + "import { dirname as __pathDirname } from 'node:path';", + 'const __filename = __fileURLToPath(import.meta.url);', + 'const __dirname = __pathDirname(__filename);', + 'const require = __createRequire(import.meta.url);', +].join('\n'); + +export async function load(url, context, nextLoad) { + if (!TS_EXT.test(new URL(url).pathname)) { + return nextLoad(url, context); + } + const file = toPath(url); + const source = await readFile(file, 'utf8'); + const { code } = await esbuild.transform(source, { + loader: file.endsWith('x') ? 'tsx' : 'ts', + format: 'esm', + target: 'node22', + sourcemap: 'inline', + sourcefile: file, + ...(CJS_GLOBALS.test(source) ? { banner: CJS_SHIM } : {}), + }); + return { format: 'module', source: code, shortCircuit: true }; +}