mirror of
https://github.com/Kong/insomnia.git
synced 2026-11-05 20:43:52 -05:00
chore(tooling): add Claude Code skills for driving and demoing the app
Two agent skills under .claude/skills/. run-insomnia drives the real Electron client through Playwright: a long-running driver with an HTTP control channel, so the app launches once and every subsequent interaction is cheap. It can click, type, take ARIA snapshots and screenshots, evaluate in either process, and read the NeDB store directly to cross-check what the UI claims. Profiles point it at the mock echo server or at a real backend, and a real login persists across relaunches. It also encodes things about this app that are not obvious from the source: renderer-level network interception does not work here, because api.protocol.ts takes over the http/https schemes and re-issues every request from the main process, so page.route sees a stub and throttling via enableNetworkEmulation or CDP measures as a no-op. netlog/throttle are built on the main-process session instead. scripts/probe-throttle.ts reproduces that measurement. demo-insomnia is for humans: after the agent reproduces a bug or finishes a feature it replays the flow one step at a time, narrating each step and waiting for the reader before the next one. profiles.json is gitignored - this repo is public and those URLs are not. Start from profiles.example.json. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
eb0b49f7a3
commit
e967bf27eb
11 files changed
+2506
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
@@ -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=<path>`.
|
||||
|
||||
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 <the worktree you want to drive>
|
||||
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/<profile>` 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 | <index> | 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/<name>`, **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":"<token>"}'
|
||||
```
|
||||
|
||||
**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 -- <title-substring>`. 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.<Type>.db` and the last line for an `_id` wins; truncating the file back
|
||||
to the good line restores the earlier state.
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Thin CLI client for driver.mjs.
|
||||
*
|
||||
* node .claude/skills/run-insomnia/drive.mjs <op> ['<json args>']
|
||||
*
|
||||
* 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 <op> ['<json>']");
|
||||
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);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, (args?: Record<string, unknown>) => Promise<unknown>>;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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() };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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/<path>` 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 };
|
||||
}
|
||||
Reference in new issue
Block a user