Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
2a61a89
chore: reduce output verbosity for local dev and AI agent workflows
jackkav May 1, 2026
eb251f9
chore: remove loglevel=warn and alias suggestions
jackkav May 1, 2026
dfae586
chore: replace setup.sh with command output guidance in AGENTS.md
jackkav May 1, 2026
31d3ac4
chore: add cx semantic code navigation guidance to AGENTS.md
jackkav May 1, 2026
3a2ba10
plan pass 2
jackkav May 1, 2026
1458e46
answer questions
jackkav May 1, 2026
e2bacc3
add tests
jackkav May 1, 2026
4c659e5
theme tests
jackkav May 4, 2026
abd5f94
more tests
jackkav May 4, 2026
9f077fb
feat: move plugin loading/execution to hidden BrowserWindow (Phase 1)
jackkav May 4, 2026
5c3ea84
feedback
jackkav May 4, 2026
12a25a6
feat: route plugin action execution through hidden BrowserWindow bridge
jackkav May 4, 2026
f7bce75
feat: route template tag listing and action execution through plugin …
jackkav May 4, 2026
2ca4baa
feat: complete Phase 1 — all plugin execution routed through hidden B…
jackkav May 4, 2026
821f01b
update plan
jackkav May 4, 2026
5834ddc
fix: initialize plugin window services and add Phase 1a E2E test
jackkav May 4, 2026
19cfd58
fix lint
jackkav May 4, 2026
bc741fe
fix: only send plugin-window-ready after successful initialization
jackkav May 4, 2026
d4b1c5e
fix assertion
jackkav May 4, 2026
4660a3c
add found
jackkav May 4, 2026
324a8c6
better
jackkav May 4, 2026
08b4aed
fix: stabilize hidden window smoke flows
jackkav May 4, 2026
369ac5a
feat: bridge request and response hooks through the plugin window
jackkav May 4, 2026
abd6878
fix: handle non-renderer processes in plugin hook functions
jackkav May 5, 2026
e566988
fix unit test
jackkav May 5, 2026
94dc96d
fix: increase findMainWindow timeout and skip plugin window by title
jackkav May 5, 2026
36a1212
fix: defer plugin window creation until after main window loads
jackkav May 5, 2026
59d584f
refactor: replace any[] cast in nunjucks context menu with narrow Con…
jackkav May 6, 2026
2f8d649
fix: safely stringify non-Error rejections in response hook error han…
jackkav May 6, 2026
b3b0f49
feat: bridge plugin UI calls (alert/dialog/prompt/clipboard) from plu…
jackkav May 6, 2026
7784ef7
fix test
jackkav May 18, 2026
2cc9904
docs
jackkav May 19, 2026
988f7a1
add tests and observability
jackkav May 19, 2026
c71d06f
docs
jackkav May 19, 2026
280b297
feat: implement invokePluginMethod for plugin communication and add t…
jackkav May 19, 2026
59017d4
document switch
jackkav May 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@
- **No unsolicited formatting.** Rely on ESLint/Prettier. Do not reformat existing code.
- **Strict scoping.** Only modify code directly related to the prompt. Do not refactor adjacent code unless asked.

## Command Output
Prefer quiet command variants to minimise output volume:
- `git log --oneline -20` not `git log`
- `git diff --stat` not `git diff`
- `npm test --silent` not `npm test`
- `tsc --noEmit 2>&1 | head -50` for type-check failures
- Use the `Read` tool with `limit` rather than `cat` on large files
- Use `Grep` with `head_limit` rather than unrestricted searches

## Validation Commands
Run from repo root before considering work complete:

Expand Down Expand Up @@ -72,3 +81,47 @@ Organization
## Sensitive Data
- **Vault system (AES-GCM):** For environment secrets (`EnvironmentKvPairDataType.SECRET`).
- **Electron safeStorage:** Platform-native encryption (`window.main.secretStorage`).
## cx — Semantic Code Navigation

Prefer cx over reading files. Escalate: overview → symbols → definition/references → Read tool.

### Quick reference

```
cx overview PATH file or directory table of contents
cx overview DIR --full directory overview with signatures
cx symbols [--kind K] [--name GLOB] [--file PATH] search symbols project-wide
cx symbols --kinds [--file PATH] list distinct kinds with counts
cx definition --name NAME [--from PATH] [--kind K] get a function/type body
cx references --name NAME [--file PATH] [--unique] find all usages (--unique: one per caller)
cx lang list show supported languages
cx lang add LANG [LANG...] install language grammars
```

Aliases: `cx o`, `cx s`, `cx d`, `cx r`

Kinds: fn, struct, enum, trait, type, const, class, interface, module, event

### Key patterns

- Start with `cx overview .`, drill into subdirectories — cheaper than ls + reading files
- `cx definition --name X` gives exact text for Edit tool's `old_string` without reading the whole file
- `cx references --name X --unique` shows one row per caller — use before refactoring to check blast radius
- After context compression, use `cx overview` / `cx definition` to re-orient — don't re-read full files
- Check signatures for `pub`/`export` to identify public API without reading the file

### Pagination

Default limits: definition 3, symbols 100, references 50. When truncated, stderr shows:

```
cx: 3/32 definitions for "X" | --from PATH to narrow | --offset 3 for more | --all
```

`--offset N` pages forward, `--all` bypasses, `--limit N` overrides. Narrowing with `--from`/`--file`/`--kind` is usually better than paging.

JSON: paginated → `{total, offset, limit, results: [...]}`, non-paginated → bare array.

### Missing grammars

If cx reports a missing grammar, install with `cx lang add <lang>`. Run `cx lang list` to see what's installed.
2 changes: 1 addition & 1 deletion packages/insomnia-smoke-test/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const config: PlaywrightTestConfig = {
sources: true,
},
},
reporter: process.env.CI ? [['github'], ['line']] : [['list']],
reporter: process.env.CI ? [['github'], ['line']] : [['dot']],
timeout: process.env.CI || isWindows ? 60 * 1000 : 20 * 1000,
forbidOnly: !!process.env.CI,
outputDir: 'traces',
Expand Down
2 changes: 2 additions & 0 deletions packages/insomnia-smoke-test/playwright/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ export const test = baseTest.extend<{
await electronApp.close();
},
page: async ({ app }, use) => {
// The plugin window is created after the main window's did-finish-load, so
// firstWindow() always returns the main app window.
const page = await app.firstWindow({ timeout: 60_000 });

await page.waitForLoadState();
Expand Down
127 changes: 127 additions & 0 deletions packages/insomnia-smoke-test/tests/smoke/plugin-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import fs from 'node:fs';
import path from 'node:path';

import { expect } from '@playwright/test';

import { loadFixture } from '../../playwright/paths';
import { test } from '../../playwright/test';

const PLUGIN_NAME = 'insomnia-plugin-bridge-test';
const ACTION_LABEL = 'Bridge Test Action';

test('Plugin bridge routes requestAction execution through hidden BrowserWindow', async ({ page, app, dataPath }) => {
// Write a minimal plugin with a requestAction to the data-path plugins directory.
const pluginDir = path.join(dataPath, 'plugins', PLUGIN_NAME);
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'package.json'),
// The 'insomnia' key is required — the loader skips packages that lack it.
JSON.stringify({ name: PLUGIN_NAME, version: '1.0.0', main: 'index.js', insomnia: {} }),
);
fs.writeFileSync(
path.join(pluginDir, 'index.js'),
`module.exports.requestActions = [{ label: '${ACTION_LABEL}', action: async () => {} }];`,
);

// Import a collection so we have a request to target.
const fixture = await loadFixture('simple.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), fixture);
await page.getByLabel('Import').click();
await page.locator('[data-test-id="import-from-clipboard"]').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();

// Reload plugins through the bridge, awaiting completion. This ensures the
// hidden BrowserWindow has started and the test plugin is registered before we
// check the UI. page.evaluate awaits the returned Promise.
await page.evaluate(() => (window as any).main.plugins.reloadPlugins());

// Open the request actions dropdown for 'example http'.
// onOpen calls window.main.plugins.getRequestActions() through the bridge.
const requestRow = page.getByLabel('Request Collection').getByRole('row', { name: 'example http' });
await requestRow.click();
await requestRow.getByLabel('Request Actions').click();

// The plugin action must appear in the dropdown, proving end-to-end bridge execution.
await expect.soft(page.getByRole('menuitemradio', { name: ACTION_LABEL })).toBeVisible();
});

test('Plugin bridge surfaces errors from plugins that throw or reject', async ({ page, dataPath }) => {
const pluginName = 'insomnia-plugin-bridge-failure';
const pluginDir = path.join(dataPath, 'plugins', pluginName);
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'package.json'),
JSON.stringify({ name: pluginName, version: '1.0.0', main: 'index.js', insomnia: {} }),
);
// Three failure shapes the bridge must normalize: sync throw, async reject with Error, async reject with non-Error.
fs.writeFileSync(
path.join(pluginDir, 'index.js'),
`
module.exports.requestActions = [
{ label: 'Sync Throw', action: () => { throw new Error('sync-boom'); } },
{ label: 'Async Reject Error', action: async () => { throw new Error('async-boom'); } },
{ label: 'Async Reject Non-Error', action: async () => { return Promise.reject('plain-string'); } },
];
`,
);

// Wait until the renderer has settled on the project route — otherwise an
// in-flight navigation destroys the evaluate execution context.
await page.getByLabel('Import').waitFor();
await page.evaluate(() => (window as any).main.plugins.reloadPlugins());

const results = await page.evaluate(async () => {
const main = (window as any).main;
const actions = await main.plugins.getRequestActions();
const outcomes: { label: string; ok: boolean; message: string | null }[] = [];
for (const action of actions.filter((a: any) => /Sync Throw|Async Reject/.test(a.label))) {
try {
await main.plugins.executeAction({
type: 'request',
pluginName: action.pluginName,
label: action.label,
projectId: '',
domainData: {},
});
outcomes.push({ label: action.label, ok: true, message: null });
} catch (err: any) {
outcomes.push({ label: action.label, ok: false, message: String(err?.message ?? err) });
}
}
return outcomes;
});

// Every failure shape must surface as a rejection to the renderer — not as a hang and not as a silent ok.
expect.soft(results.find(r => r.label === 'Sync Throw')?.ok).toBe(false);
expect.soft(results.find(r => r.label === 'Async Reject Error')?.ok).toBe(false);
expect.soft(results.find(r => r.label === 'Async Reject Non-Error')?.ok).toBe(false);

const metrics = await page.evaluate(() => (window as any).main.plugins.getBridgeMetrics());
// executeAction must have observed at least the three error outcomes we just produced.
expect.soft(metrics.perMethod.executeAction?.error ?? 0).toBeGreaterThanOrEqual(3);
});

test('Plugin bridge handles concurrent invocations without cross-talk', async ({ page, dataPath }) => {
const pluginName = 'insomnia-plugin-bridge-concurrent';
const pluginDir = path.join(dataPath, 'plugins', pluginName);
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'package.json'),
JSON.stringify({ name: pluginName, version: '1.0.0', main: 'index.js', insomnia: {} }),
);
fs.writeFileSync(path.join(pluginDir, 'index.js'), 'module.exports.requestActions = [];');

await page.getByLabel('Import').waitFor();
await page.evaluate(() => (window as any).main.plugins.reloadPlugins());

// Fire N concurrent metadata invocations. Each call assigns its own request id,
// and the bridge result handler must route results back to the correct promise.
const completed = await page.evaluate(async () => {
const main = (window as any).main;
const promises = Array.from({ length: 20 }, () => main.plugins.getRequestActions());
const results = await Promise.all(promises);
return results.every(r => Array.isArray(r)) ? results.length : -1;
});
expect.soft(completed).toBe(20);
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { expect } from '@playwright/test';
import { type ElectronApplication, expect } from '@playwright/test';

import { loadFixture } from '../../playwright/paths';
import { test } from '../../playwright/test';

const findWindowByTitle = async (app: ElectronApplication, title: string) => {
for (const window of await app.windows()) {
if ((await window.title().catch(() => '')) === title) {
return window;
}
}

throw new Error(`Window with title "${title}" not found`);
};

test.describe('test hidden window handling', () => {
test('can cancel pre-request script', async ({ app, page }) => {
test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms');
Expand Down Expand Up @@ -64,8 +74,7 @@ test.describe('test hidden window handling', () => {
await page.getByRole('tab', { name: 'Console' }).click();
await page.getByRole('tab', { name: 'Preview' }).click();

const windows = await app.windows();
const hiddenWindow = windows[1];
const hiddenWindow = await findWindowByTitle(app, 'Hidden Browser Window');
hiddenWindow.close();

await page.getByTestId('settings-button').click();
Expand Down
Loading
Loading