@postman/postman-plugin 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/dist/hosts/codex-hooks.js +68 -0
- package/dist/hosts/codex.js +87 -8
- package/dist/json-rpc.js +113 -0
- package/dist/system.js +40 -3
- package/mcp.pi.json +2 -2
- package/package.json +1 -1
- package/skills/performance-testing/SKILL.md +6 -6
- package/skills/performance-testing/references/jmeter-import/README.md +35 -66
- package/skills/performance-testing/references/jmeter-import/import-report.md +2 -2
- package/skills/performance-testing/references/jmeter-import/mapping-table.md +73 -75
- package/skills/performance-testing/references/jmeter-import/postman-perf-capabilities.md +3 -3
- package/skills/performance-testing/references/jmeter-import/script-translation.md +8 -9
- package/skills/performance-testing/references/jmeter-import/scripts/groovy_to_js.py +0 -83
- package/skills/performance-testing/references/jmeter-import/scripts/jmx_to_postman.py +0 -1433
package/README.md
CHANGED
|
@@ -28,6 +28,14 @@ One command configures **Antigravity, Claude Code, Codex, GitHub Copilot CLI, Cu
|
|
|
28
28
|
Factory Droid, Kimi Code, OpenCode and Pi**. Run it again to update, `status` to see what's installed,
|
|
29
29
|
and `remove` to uninstall; `--agent <id>` limits any of them to one agent.
|
|
30
30
|
|
|
31
|
+
For Codex, installation also approves Postman's session-start hook, which adds
|
|
32
|
+
instructions for using Postman's API skills. There is no separate hook prompt
|
|
33
|
+
or dump of its instructions. Already trusted hooks are left alone. If Codex's
|
|
34
|
+
hook API is unavailable, installation continues with a note to approve the hook
|
|
35
|
+
inside Codex. Run the installer and Codex with the same `HOME` and `CODEX_HOME`;
|
|
36
|
+
hook approval applies to that profile. If Codex later refreshes the plugin from
|
|
37
|
+
`main` and its contents change, run the installer again to approve the update.
|
|
38
|
+
|
|
31
39
|
You can also use the following commands to install individually:
|
|
32
40
|
|
|
33
41
|
### Antigravity
|
|
@@ -58,10 +66,15 @@ does nothing where the marketplace is already registered.
|
|
|
58
66
|
[View Postman on ChatGPT Plugins](https://chatgpt.com/plugins/postman?open_in_app)
|
|
59
67
|
|
|
60
68
|
```bash
|
|
61
|
-
codex plugin marketplace add postmanlabs/postman-plugin
|
|
69
|
+
codex plugin marketplace add postmanlabs/postman-plugin --ref main
|
|
70
|
+
codex plugin marketplace upgrade postman
|
|
62
71
|
codex plugin add postman@postman
|
|
63
72
|
```
|
|
64
73
|
|
|
74
|
+
These commands track `main`; review the session-start hook in Codex after a
|
|
75
|
+
manual install. The npx installer also approves the hook after each install or
|
|
76
|
+
update.
|
|
77
|
+
|
|
65
78
|
### Cursor
|
|
66
79
|
|
|
67
80
|
[View Postman on the Cursor Marketplace](https://cursor.com/marketplace/postman)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
export const POSTMAN_HOOK_KEY = 'postman@postman:hooks/codex-hooks.json:session_start:0:0';
|
|
3
|
+
async function postmanHook(rpc, cwd) {
|
|
4
|
+
const result = await rpc.request('hooks/list', { cwds: [cwd] });
|
|
5
|
+
if (!Array.isArray(result?.data) || result.data.some((entry) => !Array.isArray(entry.hooks) || entry.errors?.length)) {
|
|
6
|
+
throw new Error('hooks/list returned no usable hook listing');
|
|
7
|
+
}
|
|
8
|
+
const hooks = result.data.flatMap((entry) => entry.hooks).filter((hook) => hook.key === POSTMAN_HOOK_KEY);
|
|
9
|
+
if (hooks.length > 1) {
|
|
10
|
+
throw new Error('hooks/list returned more than one Postman session-start hook');
|
|
11
|
+
}
|
|
12
|
+
return hooks[0];
|
|
13
|
+
}
|
|
14
|
+
const trustEdit = (hash) => ({
|
|
15
|
+
edits: [{ keyPath: 'hooks.state', value: { [POSTMAN_HOOK_KEY]: { trusted_hash: hash } }, mergeStrategy: 'upsert' }],
|
|
16
|
+
reloadUserConfig: true
|
|
17
|
+
});
|
|
18
|
+
/** Approves only Postman's current hook after installation; unsupported APIs never fail the install. */
|
|
19
|
+
export async function approvePostmanHook(system) {
|
|
20
|
+
const note = (message) => system.log(` note: ${message}`);
|
|
21
|
+
let writeAttempted = false;
|
|
22
|
+
if (system.dryRun) {
|
|
23
|
+
note('Would inspect and approve Postman\'s current session-start hook after installation through codex app-server:');
|
|
24
|
+
system.log(` config/batchWrite ${JSON.stringify(trustEdit('<currentHash from hooks/list>'))}`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
await system.withJsonRpc('codex', ['app-server'], async (rpc) => {
|
|
29
|
+
// Version of this small protocol client, independent of the plugin's release.
|
|
30
|
+
await rpc.request('initialize', { clientInfo: { name: 'postman-plugin-installer', version: '1' } });
|
|
31
|
+
rpc.notify('initialized');
|
|
32
|
+
const hook = await postmanHook(rpc, system.cwd);
|
|
33
|
+
if (!hook) {
|
|
34
|
+
note('Postman\'s session-start hook was not found; hook approval skipped.');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (hook.trustStatus === 'trusted') {
|
|
38
|
+
note('Postman\'s Codex session-start hook is already trusted.');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (!['untrusted', 'modified'].includes(hook.trustStatus) || !hook.currentHash || typeof hook.currentHash !== 'string') {
|
|
42
|
+
throw new Error('Postman\'s hook has an unsupported trust status or hash');
|
|
43
|
+
}
|
|
44
|
+
// Confirm the installed instructions are present before approving their hook.
|
|
45
|
+
const mandateFile = path.resolve(path.dirname(hook.sourcePath), 'session-start-context.md'), mandate = await system.readFile(mandateFile);
|
|
46
|
+
if (!mandate?.trim()) {
|
|
47
|
+
throw new Error('could not read the installed hooks/session-start-context.md');
|
|
48
|
+
}
|
|
49
|
+
// Startup can refresh marketplaces. A changed hash needs another check,
|
|
50
|
+
// rather than approving a different version from the one inspected above.
|
|
51
|
+
const current = await postmanHook(rpc, system.cwd);
|
|
52
|
+
if (!current || current.currentHash !== hook.currentHash || !['untrusted', 'modified'].includes(current.trustStatus)) {
|
|
53
|
+
note('Postman\'s hook changed while checking it; approval skipped. Re-run to approve the current version.');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
writeAttempted = true;
|
|
57
|
+
await rpc.request('config/batchWrite', trustEdit(hook.currentHash));
|
|
58
|
+
const after = await postmanHook(rpc, system.cwd);
|
|
59
|
+
note(after?.trustStatus === 'trusted' && after.currentHash === hook.currentHash ?
|
|
60
|
+
'Approved Postman\'s Codex session-start hook.' :
|
|
61
|
+
'Codex accepted the approval write, but the hook is not confirmed trusted. Review it in Codex.');
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
// Older Codex releases may lack app-server or hooks/list. Installation still succeeds.
|
|
66
|
+
note(`Hook approval ${writeAttempted ? 'could not be confirmed' : 'skipped'}: ${error instanceof Error ? error.message : String(error)}. Review the hook in Codex.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
package/dist/hosts/codex.js
CHANGED
|
@@ -1,20 +1,39 @@
|
|
|
1
|
-
import { REPO, isSameRepo, redact } from '../source.js';
|
|
2
|
-
import { blocked, failed, guard, mustProbeJson, mustRun, parseJson } from './shared.js';
|
|
1
|
+
import { BRANCH, GIT_URL, REPO, isSameRepo, redact } from '../source.js';
|
|
2
|
+
import { StepFailed, blocked, failed, guard, mustProbeJson, mustRun, parseJson } from './shared.js';
|
|
3
3
|
import { result } from './types.js';
|
|
4
|
+
import { approvePostmanHook } from './codex-hooks.js';
|
|
4
5
|
// Codex names the marketplace after `.claude-plugin/marketplace.json`'s `name`.
|
|
5
6
|
// `npx plugins add` installs a second copy under its own `plugins-cli` marketplace.
|
|
6
|
-
const MARKETPLACE = 'postman', PLUGIN_ID = `postman@${MARKETPLACE}`, SHADOW_IDS = ['postman@plugins-cli'], NEXT = 'Restart Codex for the change to take effect.';
|
|
7
|
+
const MARKETPLACE = 'postman', PLUGIN_ID = `postman@${MARKETPLACE}`, LEGACY_REF = 'codex-release', SHADOW_IDS = ['postman@plugins-cli'], NEXT = 'Restart Codex for the change to take effect.';
|
|
7
8
|
function isInstalled(plugins, id) {
|
|
8
9
|
return plugins.some((plugin) => plugin.pluginId === id && plugin.installed !== false);
|
|
9
10
|
}
|
|
10
11
|
async function listPlugins(system) {
|
|
11
12
|
return (await mustProbeJson(system, 'codex', ['plugin', 'list', '--json'])).installed ?? [];
|
|
12
13
|
}
|
|
14
|
+
/** The marketplace's ref as Codex's own config reader reports it; `marketplace list --json` doesn't. */
|
|
15
|
+
export async function registeredRef(system) {
|
|
16
|
+
const replies = await system.rpc('codex', ['app-server'], [
|
|
17
|
+
{ method: 'initialize', params: { clientInfo: { name: 'postman-plugin', version: '0' } } },
|
|
18
|
+
{ method: 'initialized', notify: true },
|
|
19
|
+
{ method: 'config/read', params: {} }
|
|
20
|
+
]), read = replies?.[1]?.result, entry = read?.config?.marketplaces?.[MARKETPLACE];
|
|
21
|
+
if (!entry || typeof entry !== 'object') {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return typeof entry.ref === 'string' ? entry.ref : entry.ref === null || entry.ref === undefined ? null : undefined;
|
|
25
|
+
}
|
|
26
|
+
async function addMarketplace(system) {
|
|
27
|
+
await mustRun(system, 'codex', ['plugin', 'marketplace', 'add', REPO, '--ref', BRANCH, '--json']);
|
|
28
|
+
// `add` records no revision, so without this Codex's next startup refetches the plugin and asks for approval again.
|
|
29
|
+
await mustRun(system, 'codex', ['plugin', 'marketplace', 'upgrade', MARKETPLACE, '--json']);
|
|
30
|
+
}
|
|
31
|
+
/** Adds or upgrades the marketplace, leaving the old release branch for `migrate`. */
|
|
13
32
|
async function refreshMarketplace(system) {
|
|
14
33
|
const listing = await mustProbeJson(system, 'codex', ['plugin', 'marketplace', 'list', '--json']), existing = listing.marketplaces?.find((marketplace) => marketplace.name === MARKETPLACE);
|
|
15
34
|
if (!existing) {
|
|
16
|
-
await
|
|
17
|
-
return;
|
|
35
|
+
await addMarketplace(system);
|
|
36
|
+
return 'ready';
|
|
18
37
|
}
|
|
19
38
|
// A local marketplace can carry any path text, so only a git source counts as this repo.
|
|
20
39
|
const { sourceType, source } = existing.marketplaceSource ?? {};
|
|
@@ -22,7 +41,58 @@ async function refreshMarketplace(system) {
|
|
|
22
41
|
const from = source ? `${sourceType ?? 'unknown'} source ${redact(source)}` : 'an unknown source';
|
|
23
42
|
blocked(`marketplace ${MARKETPLACE} is registered from ${from}, not ${REPO}`);
|
|
24
43
|
}
|
|
25
|
-
|
|
44
|
+
if (system.dryRun) {
|
|
45
|
+
system.log(` note: Would ask Codex for marketplace ${MARKETPLACE}'s ref: upgrade it on ${BRANCH}, migrate ${LEGACY_REF} to ${BRANCH}, preserve an unreadable ref, or stop for a custom ref. App-server is deferred because its startup can refresh plugins.`);
|
|
46
|
+
return 'deferred';
|
|
47
|
+
}
|
|
48
|
+
const ref = await registeredRef(system);
|
|
49
|
+
if (ref === undefined) {
|
|
50
|
+
system.log(` note: Could not read marketplace ${MARKETPLACE}'s ref through codex app-server; keeping its registration and skipping ref migration.`);
|
|
51
|
+
}
|
|
52
|
+
if (ref === BRANCH || ref === null || ref === undefined) {
|
|
53
|
+
await mustRun(system, 'codex', ['plugin', 'marketplace', 'upgrade', MARKETPLACE, '--json']);
|
|
54
|
+
return 'ready';
|
|
55
|
+
}
|
|
56
|
+
if (ref !== LEGACY_REF) {
|
|
57
|
+
blocked(`marketplace ${MARKETPLACE} tracks ${ref}, not ${BRANCH}; run \`codex plugin marketplace remove ${MARKETPLACE}\` and re-run`);
|
|
58
|
+
}
|
|
59
|
+
return 'legacy';
|
|
60
|
+
}
|
|
61
|
+
/** Re-registers the previous marketplace and its plugin; true when every step exits 0. */
|
|
62
|
+
async function restoreLegacy(system, wasInstalled) {
|
|
63
|
+
// Exits non-zero when the failed step left nothing registered, which is the state wanted here.
|
|
64
|
+
await system.run('codex', ['plugin', 'marketplace', 'remove', MARKETPLACE, '--json']);
|
|
65
|
+
const steps = [['plugin', 'marketplace', 'add', REPO, '--ref', LEGACY_REF, '--json'], ...(wasInstalled ? [['plugin', 'add', PLUGIN_ID, '--json']] : [])];
|
|
66
|
+
for (const args of steps) {
|
|
67
|
+
if ((await system.run('codex', args)).code !== 0) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Moves a marketplace registered at the old release branch to main, then adds the plugin. `remove`
|
|
75
|
+
* uninstalls Postman too, so a failure after it puts the previous registration back.
|
|
76
|
+
*/
|
|
77
|
+
async function migrate(system, wasInstalled) {
|
|
78
|
+
const reach = await system.probe('git', ['ls-remote', '--exit-code', GIT_URL, `refs/heads/${BRANCH}`]);
|
|
79
|
+
if (reach.code !== 0) {
|
|
80
|
+
failed(`can't reach ${BRANCH} in ${REPO}, so marketplace ${MARKETPLACE} was left tracking ${LEGACY_REF}`);
|
|
81
|
+
}
|
|
82
|
+
await mustRun(system, 'codex', ['plugin', 'marketplace', 'remove', MARKETPLACE, '--json']);
|
|
83
|
+
try {
|
|
84
|
+
await addMarketplace(system);
|
|
85
|
+
await mustRun(system, 'codex', ['plugin', 'add', PLUGIN_ID, '--json']);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (!(error instanceof StepFailed)) {
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
const restored = await restoreLegacy(system, wasInstalled);
|
|
92
|
+
failed(`${error.outcome.message}\n${restored ?
|
|
93
|
+
`restored marketplace ${MARKETPLACE} tracking ${LEGACY_REF}${wasInstalled ? ` and ${PLUGIN_ID}` : ''}` :
|
|
94
|
+
`could not restore it either; run \`codex plugin marketplace remove ${MARKETPLACE}\`, \`codex plugin marketplace add ${REPO} --ref ${BRANCH}\`, \`codex plugin marketplace upgrade ${MARKETPLACE}\` and \`codex plugin add ${PLUGIN_ID}\``}`);
|
|
95
|
+
}
|
|
26
96
|
}
|
|
27
97
|
async function removeInstalled(system, plugins, ids) {
|
|
28
98
|
const present = ids.filter((id) => isInstalled(plugins, id));
|
|
@@ -44,18 +114,27 @@ export const codex = {
|
|
|
44
114
|
return { installed: null, detail: 'could not read `codex plugin list --json`', notes: [] };
|
|
45
115
|
}
|
|
46
116
|
const installed = plugins.find((plugin) => plugin.pluginId === PLUGIN_ID && plugin.installed !== false), notes = SHADOW_IDS.filter((id) => isInstalled(plugins, id)).map((id) => `${id} duplicates it and will be removed`);
|
|
117
|
+
if (installed && await registeredRef(system) === LEGACY_REF) {
|
|
118
|
+
notes.push(`marketplace ${MARKETPLACE} tracks ${LEGACY_REF} and will be re-added at ${BRANCH}; the installer then approves its session-start hook`);
|
|
119
|
+
}
|
|
47
120
|
return installed ?
|
|
48
121
|
{ installed: true, detail: `${PLUGIN_ID} ${installed.version ?? ''}`.trim(), notes } :
|
|
49
122
|
{ installed: false, detail: 'not installed', notes };
|
|
50
123
|
},
|
|
51
124
|
install(system) {
|
|
52
125
|
return guard(async () => {
|
|
53
|
-
|
|
126
|
+
// Listed first: re-registering the marketplace uninstalls its plugins.
|
|
54
127
|
const plugins = await listPlugins(system), wasInstalled = isInstalled(plugins, PLUGIN_ID);
|
|
55
128
|
// `add` is idempotent and is also how Codex updates an installed plugin. It runs
|
|
56
129
|
// before the duplicate is removed, so a failed add still leaves a working copy.
|
|
57
|
-
await
|
|
130
|
+
if (await refreshMarketplace(system) === 'legacy') {
|
|
131
|
+
await migrate(system, wasInstalled);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
await mustRun(system, 'codex', ['plugin', 'add', PLUGIN_ID, '--json']);
|
|
135
|
+
}
|
|
58
136
|
await removeInstalled(system, plugins, SHADOW_IDS);
|
|
137
|
+
await approvePostmanHook(system);
|
|
59
138
|
return result('done', `${wasInstalled ? 'updated' : 'installed'} ${PLUGIN_ID}`, NEXT);
|
|
60
139
|
});
|
|
61
140
|
},
|
package/dist/json-rpc.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
export class JsonRpcError extends Error {
|
|
4
|
+
reply;
|
|
5
|
+
constructor(reply) {
|
|
6
|
+
super(`app-server RPC error ${reply.code ?? ''}: ${reply.message ?? 'unknown error'}`);
|
|
7
|
+
this.reply = reply;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** One stdio session with a deadline for each request. */
|
|
11
|
+
export async function withJsonRpc(file, args, env, use, timeoutMs) {
|
|
12
|
+
// npm's Windows launchers need cmd.exe, just as System.run's launchers do.
|
|
13
|
+
const quote = (arg) => /^[\w@./:=-]+$/.test(arg) ? arg : `"${arg}"`, shell = /\.(cmd|bat)$/i.test(file), child = shell ?
|
|
14
|
+
spawn(quote(file), args.map(quote), { env, shell: true, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }) :
|
|
15
|
+
spawn(file, args, { env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }), lines = createInterface({ input: child.stdout }), pending = new Map();
|
|
16
|
+
let nextId = 0, failure;
|
|
17
|
+
function fail(error) {
|
|
18
|
+
failure = error;
|
|
19
|
+
for (const entry of pending.values()) {
|
|
20
|
+
clearTimeout(entry.timer);
|
|
21
|
+
entry.reject(error);
|
|
22
|
+
}
|
|
23
|
+
pending.clear();
|
|
24
|
+
}
|
|
25
|
+
child.on('error', fail);
|
|
26
|
+
child.stdin.on('error', fail);
|
|
27
|
+
// Drain diagnostics without putting arbitrary app-server output into installer logs.
|
|
28
|
+
child.stderr.resume();
|
|
29
|
+
const closed = new Promise((resolve) => child.once('close', (code) => {
|
|
30
|
+
fail(new Error(`app-server closed (exit ${code ?? 'signal'})`));
|
|
31
|
+
resolve();
|
|
32
|
+
}));
|
|
33
|
+
lines.on('line', (line) => {
|
|
34
|
+
let message;
|
|
35
|
+
try {
|
|
36
|
+
message = JSON.parse(line);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
fail(new Error('app-server returned invalid JSON'));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// Notifications are not replies. A server request is unsupported by this client.
|
|
43
|
+
if (message && typeof message.method === 'string') {
|
|
44
|
+
if (message.id !== undefined) {
|
|
45
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Method not found' } }) + '\n');
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const entry = pending.get(message?.id);
|
|
50
|
+
if (!entry) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
clearTimeout(entry.timer);
|
|
54
|
+
pending.delete(message.id);
|
|
55
|
+
if (message.error) {
|
|
56
|
+
entry.reject(new JsonRpcError(message.error));
|
|
57
|
+
}
|
|
58
|
+
else if ('result' in message) {
|
|
59
|
+
entry.resolve(message.result);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
entry.reject(new Error('app-server reply has no result'));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
return await use({
|
|
67
|
+
request(method, params) {
|
|
68
|
+
if (failure) {
|
|
69
|
+
return Promise.reject(failure);
|
|
70
|
+
}
|
|
71
|
+
const id = ++nextId;
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const timer = setTimeout(() => fail(new Error(`app-server timed out waiting for ${method}`)), timeoutMs);
|
|
74
|
+
pending.set(id, { resolve: (value) => resolve(value), reject, timer });
|
|
75
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
notify(method, params) {
|
|
79
|
+
if (failure) {
|
|
80
|
+
throw failure;
|
|
81
|
+
}
|
|
82
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
fail(new Error('app-server session ended'));
|
|
88
|
+
lines.close();
|
|
89
|
+
child.stdin.end();
|
|
90
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
91
|
+
if (shell && process.platform === 'win32' && child.pid) {
|
|
92
|
+
// Killing cmd.exe alone leaves its Codex child holding the pipes open.
|
|
93
|
+
spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true }).on('error', () => child.kill());
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
child.kill();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
let force;
|
|
100
|
+
const forced = new Promise((resolve) => {
|
|
101
|
+
force = setTimeout(() => {
|
|
102
|
+
child.kill('SIGKILL');
|
|
103
|
+
// A descendant retaining a pipe must not hold the installer open.
|
|
104
|
+
child.stdin.destroy();
|
|
105
|
+
child.stdout.destroy();
|
|
106
|
+
child.stderr.destroy();
|
|
107
|
+
resolve();
|
|
108
|
+
}, 1000);
|
|
109
|
+
});
|
|
110
|
+
await Promise.race([closed, forced]);
|
|
111
|
+
clearTimeout(force);
|
|
112
|
+
}
|
|
113
|
+
}
|
package/dist/system.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { redact } from './source.js';
|
|
6
|
+
import { JsonRpcError, withJsonRpc } from './json-rpc.js';
|
|
6
7
|
// A missing file, or a path through something that isn't a directory. Anything else,
|
|
7
8
|
// such as a file that exists but can't be read, must not pass for "absent".
|
|
8
9
|
const ABSENT = ['ENOENT', 'ENOTDIR'];
|
|
@@ -35,8 +36,8 @@ function execute(file, args, env) {
|
|
|
35
36
|
export function formatCommand(command, args) {
|
|
36
37
|
return [command, ...args].map((part) => redact(part)).map((part) => (/^[\w@./:=~-]+$/.test(part) ? part : JSON.stringify(part))).join(' ');
|
|
37
38
|
}
|
|
38
|
-
export function createSystem({ dryRun = false, log = (line) => console.log(line) } = {}) {
|
|
39
|
-
const
|
|
39
|
+
export function createSystem({ dryRun = false, log = (line) => console.log(line), env = process.env, home = os.homedir(), cwd = process.cwd() } = {}) {
|
|
40
|
+
const platform = process.platform;
|
|
40
41
|
async function which(command) {
|
|
41
42
|
const extensions = platform === 'win32' ? (env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : [''];
|
|
42
43
|
for (const dir of (env.PATH || '').split(path.delimiter).filter(Boolean)) {
|
|
@@ -59,10 +60,11 @@ export function createSystem({ dryRun = false, log = (line) => console.log(line)
|
|
|
59
60
|
return (await which(command)) || command;
|
|
60
61
|
}
|
|
61
62
|
return {
|
|
62
|
-
home
|
|
63
|
+
home,
|
|
63
64
|
platform,
|
|
64
65
|
env,
|
|
65
66
|
dryRun,
|
|
67
|
+
cwd,
|
|
66
68
|
which,
|
|
67
69
|
async exists(file) {
|
|
68
70
|
try {
|
|
@@ -101,6 +103,35 @@ export function createSystem({ dryRun = false, log = (line) => console.log(line)
|
|
|
101
103
|
async probe(command, args) {
|
|
102
104
|
return execute(await resolve(command), args, env);
|
|
103
105
|
},
|
|
106
|
+
async rpc(command, args, calls) {
|
|
107
|
+
if (dryRun) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
return await withJsonRpc(await resolve(command), args, env, async (rpc) => {
|
|
112
|
+
const replies = [];
|
|
113
|
+
for (const call of calls) {
|
|
114
|
+
if (call.notify) {
|
|
115
|
+
rpc.notify(call.method, call.params);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
replies.push({ result: await rpc.request(call.method, call.params) });
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (!(error instanceof JsonRpcError)) {
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
replies.push({ error: error.reply });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return replies;
|
|
129
|
+
}, 30000);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
104
135
|
async run(command, args, options = {}) {
|
|
105
136
|
log(` $ ${formatCommand(command, args)}`);
|
|
106
137
|
if (dryRun) {
|
|
@@ -108,6 +139,12 @@ export function createSystem({ dryRun = false, log = (line) => console.log(line)
|
|
|
108
139
|
}
|
|
109
140
|
return execute(await resolve(command), args, { ...env, ...options.env });
|
|
110
141
|
},
|
|
142
|
+
async withJsonRpc(command, args, use, timeoutMs = 15000) {
|
|
143
|
+
log(` $ ${formatCommand(command, args)} (stdio RPC)`);
|
|
144
|
+
if (!dryRun) {
|
|
145
|
+
return withJsonRpc(await resolve(command), args, env, use, timeoutMs);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
111
148
|
async writeFile(file, content) {
|
|
112
149
|
log(` write ${file}`);
|
|
113
150
|
if (!dryRun) {
|
package/mcp.pi.json
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
"description": "Postman workspaces, collections, specs, environments, mocks and monitors",
|
|
7
7
|
"headers": {
|
|
8
8
|
"X-Source": "postman-pi-plugin",
|
|
9
|
-
"X-Plugin-Version": "0.4.
|
|
10
|
-
"User-Agent": "postman-pi-plugin/0.4.
|
|
9
|
+
"X-Plugin-Version": "0.4.1",
|
|
10
|
+
"User-Agent": "postman-pi-plugin/0.4.1"
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postman/postman-plugin",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Postman's API engineering skills for coding agents: a Pi package, and an npx installer that sets up Claude Code, Codex, Cursor, Kimi Code, Factory Droid, OpenCode and Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -39,12 +39,12 @@ Collections run directly: converting with `postman-to-k6` is not required.
|
|
|
39
39
|
|
|
40
40
|
## Importing from JMeter
|
|
41
41
|
|
|
42
|
-
Have a JMeter `.jmx` instead of a collection?
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
Have a JMeter `.jmx` instead of a collection? Import it by following
|
|
43
|
+
`references/jmeter-import/README.md`, then treat the result as the collection
|
|
44
|
+
above. Its mapping table carries the JMeter semantics that are easy to get
|
|
45
|
+
wrong, and the import report marks what mapped exactly, what was approximated,
|
|
46
|
+
and which JMeter features have no equivalent here — think-time timers,
|
|
47
|
+
concurrent thread groups, and non-HTTP (JDBC/FTP/LDAP/Java) samplers are
|
|
48
48
|
reported, never faked.
|
|
49
49
|
|
|
50
50
|
## Verification
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
|
|
2
1
|
# Importing a JMeter `.jmx` into Postman
|
|
3
2
|
|
|
4
|
-
> Reference for the `performance-testing` skill.
|
|
3
|
+
> Reference for the `performance-testing` skill. Import the plan, review the report, then the result is an ordinary performance run.
|
|
5
4
|
|
|
6
5
|
Turn a JMeter test plan into three artifacts:
|
|
7
6
|
|
|
@@ -9,65 +8,57 @@ Turn a JMeter test plan into three artifacts:
|
|
|
9
8
|
2. **Performance config** — VU count, duration, load profile, data file, `--pass-if`, and the exact `postman performance run` command.
|
|
10
9
|
3. **Import report** — what imported exactly, what was approximated, what needs manual work, what is unsupported.
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
## When NOT to guess
|
|
15
|
-
|
|
16
|
-
Postman performance runs do not support everything JMeter does. The scripts already encode the gaps (see `postman-perf-capabilities.md`). Never invent a Postman feature to cover a JMeter one. If something can't import, it goes in the report's "Needs manual work" or "Not supported" section — that is a correct outcome, not a failure.
|
|
11
|
+
There is no importer to run: you read the `.jmx` and build these yourself. The files next to this one are the algorithm:
|
|
17
12
|
|
|
18
|
-
|
|
13
|
+
- `mapping-table.md` — what each JMeter element becomes, and how faithfully.
|
|
14
|
+
- `postman-perf-capabilities.md` — the hard limits of the import target.
|
|
15
|
+
- `script-translation.md` — Groovy/BeanShell → JS, and the JS templates for extractors and assertions.
|
|
16
|
+
- `import-report.md` — the report's shape.
|
|
19
17
|
|
|
20
|
-
|
|
18
|
+
## When NOT to guess
|
|
21
19
|
|
|
22
|
-
|
|
23
|
-
- **Port 0 / -1** → "use protocol default" → omitted from the URL.
|
|
24
|
-
- **Full-URL path** — if `HTTPSampler.path` is itself an absolute URL, it's used verbatim; domain/port are ignored.
|
|
25
|
-
- **HEAD / TRACE arguments** — JMeter does not send separate parameters as a query for these; they're dropped (reported).
|
|
26
|
-
- **Duplicate headers** — in-scope header managers merge; the nearer/last value per name wins and the collapse is reported.
|
|
27
|
-
- **Sampler-scoped config** — a Header Manager / Auth Manager / HTTP Defaults inside a request's own subtree applies to that request.
|
|
28
|
-
- **Security** — the parser refuses DTD/ENTITY (XXE) and caps input at 25 MiB.
|
|
20
|
+
Postman performance runs do not support everything JMeter does (see `postman-perf-capabilities.md`). Never invent a Postman feature to cover a JMeter one. If something can't import, it goes in the report's "Needs manual work" or "Not supported" section — that is a correct outcome, not a failure.
|
|
29
21
|
|
|
30
22
|
## Workflow
|
|
31
23
|
|
|
32
|
-
### 1.
|
|
33
|
-
|
|
34
|
-
```bash
|
|
35
|
-
python3 scripts/jmx_to_postman.py INPUT.jmx -o OUTDIR --json-report
|
|
36
|
-
# CI gate: add --fail-on-blockers to exit non-zero when any item needs manual work
|
|
37
|
-
```
|
|
24
|
+
### 1. Walk the plan
|
|
38
25
|
|
|
39
|
-
|
|
40
|
-
- `<name>.postman_collection.json` — the collection (import-ready)
|
|
41
|
-
- `<name>.perf.md` / `<name>.perf.json` — performance config + CLI command
|
|
42
|
-
- `<name>.report.md` — the import report
|
|
43
|
-
- `<name>.ir.json` — machine-readable intermediate model + `manual_tasks` + `warnings` (from `--json-report`)
|
|
26
|
+
JMX has one structural rule: every element is followed by a sibling `<hashTree>` holding its children. Walk those pairs. A config element (Header Manager, Auth Manager, HTTP Defaults, extractor, assertion) applies to every sampler in its subtree; the same element inside a sampler's own hashTree applies only to that sampler. For a large plan, work one thread group or controller at a time.
|
|
44
27
|
|
|
45
|
-
|
|
28
|
+
### 2. Apply the JMeter semantics
|
|
46
29
|
|
|
47
|
-
|
|
30
|
+
These are easy to get subtly wrong:
|
|
48
31
|
|
|
49
|
-
|
|
32
|
+
- **Implicit HTTP success** — JMeter fails a sample on HTTP ≥ 400 even with no assertion. Every request gets the default 200–399 `pm.test` from `script-translation.md`, **unless** it has a Response Assertion whose `test_field` is `response_code` or `response_message`, or any assertion with `assume_success=true` (Ignore Status). An assertion on the body (`response_data`) does not suppress it.
|
|
33
|
+
- **Port 0 / -1** → "use protocol default" → omit from the URL.
|
|
34
|
+
- **Full-URL path** — if `HTTPSampler.path` starts with `http://` or `https://`, use it verbatim and ignore domain/port.
|
|
35
|
+
- **HEAD / TRACE arguments** — JMeter doesn't send separate parameters as a query for these; drop them and note it.
|
|
36
|
+
- **Headers** — in-scope Header Managers merge; the nearer/last value per name wins, and the collapse is reported. Keep inherited headers even on a GET (JMeter still sends them).
|
|
37
|
+
- **Variables and functions** — `${var}` → `{{var}}`; `${__UUID}` → `{{$guid}}`, `${__time}` → `{{$timestamp}}`, `${__Random(..)}` → `{{$randomInt}}`, `${__RandomString(..)}` → `{{$randomAlphaNumeric}}`.
|
|
38
|
+
- **Extractors and assertions** — extractors become `pm.collectionVariables.set` in the test script, assertions become `pm.test`. Use the templates in `script-translation.md` so every import produces the same code.
|
|
39
|
+
- **Load** — VUs = `num_threads`; duration (minutes) = round(`ThreadGroup.duration` seconds ÷ 60), minimum 1; profile = `ramp-up` if `ramp_time` > 0 or it's a plugin/concurrency group, else `fixed`. Loop counts become duration (there is no iteration count). One `--pass-if` only: a Duration Assertion → `less_than(p95, <ms>)`, otherwise default to `less_than(error_rate, 5)`.
|
|
40
|
+
- **Timers, concurrent thread groups, non-HTTP samplers** → not supported. List them in the report; never fake them.
|
|
50
41
|
|
|
51
|
-
|
|
52
|
-
- **`script_sampler`** — a standalone JSR223/BeanShell *sampler* (no HTTP). The stub is a GET to an empty URL carrying the translated script. Decide with the user: delete it, fold its logic into an adjacent request's pre/post script, or replace with a real request.
|
|
53
|
-
- **`xpath` / `css`** — the Postman sandbox has no built-in XPath/CSS selector engine. Rewrite the extractor against the JSON body if the response is JSON, or use `xml2js`/`cheerio` via `require(...)` in the script (confirm availability), or drop it with a note.
|
|
54
|
-
- **`jsonpath` too complex** — the path used wildcards/filters/recursion. Rewrite it as explicit property access in the request's test script.
|
|
42
|
+
### 3. Handle what needs judgment
|
|
55
43
|
|
|
56
|
-
|
|
44
|
+
- **JSR223/BeanShell pre/post-processors** — translate to a prerequest/test script with `script-translation.md`. Mark anything you're unsure of with a `// REVIEW` comment and list it under "Needs manual work".
|
|
45
|
+
- **JSR223/BeanShell samplers** (no HTTP) — decide with the user: delete it, fold its logic into an adjacent request's script, or replace it with a real request.
|
|
46
|
+
- **XPath / CSS extractors** — the sandbox has no XPath/CSS engine. Rewrite against the JSON body if the response is JSON, use `xml2js`/`cheerio` via `require(...)` (confirm availability), or drop it with a note.
|
|
47
|
+
- **Complex JSONPath** (wildcards, filters, recursion) — rewrite as explicit property access in the test script.
|
|
57
48
|
|
|
58
|
-
###
|
|
49
|
+
### 4. Validate the collection
|
|
59
50
|
|
|
60
|
-
Confirm the collection is valid v2.1 JSON and
|
|
51
|
+
Confirm the collection is valid v2.1 JSON and that **every `event` script parses as JavaScript** (`node --check` each `exec`, or equivalent). A syntax error in a script is invisible in the file and only surfaces when the perf run executes that request, mid-load.
|
|
61
52
|
|
|
62
|
-
###
|
|
53
|
+
### 5. Write the report
|
|
63
54
|
|
|
64
|
-
|
|
55
|
+
Fill in `import-report.md`'s sections and counts so the report matches the collection you're handing off.
|
|
65
56
|
|
|
66
|
-
###
|
|
57
|
+
### 6. Hand off the perf config
|
|
67
58
|
|
|
68
|
-
|
|
59
|
+
The command needs a collection UID, so the collection must be in a workspace first. The `--pass-if` value is a suggestion unless the JMeter plan had a Duration Assertion, so surface the VU/duration/profile/pass-if choices for the user to adjust.
|
|
69
60
|
|
|
70
|
-
###
|
|
61
|
+
### 7. (optional) Publish to a Postman workspace
|
|
71
62
|
|
|
72
63
|
Only on explicit user consent — and **never make the user paste a workspace ID**. Pick the destination like this:
|
|
73
64
|
|
|
@@ -76,38 +67,16 @@ Only on explicit user consent — and **never make the user paste a workspace ID
|
|
|
76
67
|
- **Otherwise present a choice, don't demand an ID.** Run `postman workspace list --json`, show the **names**, and ask the user to pick one *or* create a new one:
|
|
77
68
|
- **pick existing** → connect it once with `postman workspace pull <id>` (ID taken from the list), then push.
|
|
78
69
|
- **create new** → the CLI has no `create`; make it via MCP `createWorkspace` (or the Postman app), then `pull` and push.
|
|
79
|
-
- **Get the imported collection into the bound workspace before pushing.** The
|
|
70
|
+
- **Get the imported collection into the bound workspace before pushing.** The import is a standalone v2.1 `*.postman_collection.json`; a pulled workspace is a git-native v3 tree. After `pull`, bring the file in with `postman collection migrate <file>.postman_collection.json` into the workspace's collections directory (check `postman collection migrate -h`), then `postman workspace push`. Pushing a folder that has no collection in its tree uploads nothing.
|
|
80
71
|
- **Push through the CLI, not MCP.** MCP `createCollection` drops each request's URL (requests land blank); use MCP only if the `postman` binary genuinely can't be installed, and repair URLs afterward.
|
|
81
72
|
- Interrogate `postman <cmd> -h` for exact flags — the help is authoritative, this file is not.
|
|
82
73
|
|
|
83
74
|
Smoke-test with `postman collection run` — the **same runtime that executes the perf test**, not a generic client — so the request is proven under the variable/auth resolution it will actually run under. Then wire the returned UID into the perf command.
|
|
84
75
|
|
|
85
|
-
## Fallback: when you cannot run Python
|
|
86
|
-
|
|
87
|
-
If this environment has no code execution, do **not** refuse — import by hand, following the exact same rules the script encodes. Output must be identical in shape (collection v2.1 + perf config + report).
|
|
88
|
-
|
|
89
|
-
1. **Read first:** `mapping-table.md` (what maps to what), `postman-perf-capabilities.md` (the hard limits), `script-translation.md` (Groovy/BeanShell → JS). These ARE the algorithm.
|
|
90
|
-
2. **Parse the JMX by the one structural rule:** every element is followed by a sibling `<hashTree>` holding its children. Walk those pairs. A config element (Header Manager, Auth, HTTP Defaults, extractor, assertion) applies to every sampler in its subtree; the same element inside a sampler's own hashTree applies only to that sampler.
|
|
91
|
-
3. **Apply the non-negotiable rules** (same as the script). Use the **exact JS templates in `script-translation.md`** for extractors and assertions so a hand import matches the script output byte-for-byte:
|
|
92
|
-
- `${var}` → `{{var}}`; `${__UUID}`→`{{$guid}}`, `${__time}`→`{{$timestamp}}`, `${__Random(..)}`→`{{$randomInt}}`, `${__RandomString(..)}`→`{{$randomAlphaNumeric}}`.
|
|
93
|
-
- Every request gets a default `pm.test` asserting HTTP 200–399 — **unless** that request has a Response Assertion whose `test_field` is `response_code` **or** `response_message`, **or** any assertion with `assume_success=true`. An assertion on the **body** (`response_data`) does NOT suppress it.
|
|
94
|
-
- Port 0/-1 → omit. Full-URL path (`path` starts with `http://`/`https://`) → use verbatim, ignore domain/port. HEAD/TRACE args → drop + note. Keep inherited headers as-is even on a GET (JMeter still sends them); don't prune.
|
|
95
|
-
- Extractors → `pm.collectionVariables.set` in the test script; assertions → `pm.test`. (Templates in the reference.)
|
|
96
|
-
- Thread group → VUs = `num_threads`; **duration (minutes) = round(`ThreadGroup.duration` seconds ÷ 60), minimum 1**; profile = `ramp-up` if `ramp_time`>0 or a plugin/concurrency group, else `fixed`. Loop counts → duration (there is no iteration count). One `--pass-if` only; a Duration Assertion → `less_than(p95, <ms>)`, else default `less_than(error_rate, 5)`.
|
|
97
|
-
- Timers / concurrent thread groups / non-HTTP samplers → **not supported**; list them in the report, never fake them.
|
|
98
|
-
4. **Produce the same files** a run would: the collection JSON, the perf `.md`, and the report with the four sections (imported / approximated / needs manual work / not supported).
|
|
99
|
-
5. **State clearly** in your answer that this was a by-hand import (no Python), so it's best-effort and not the deterministic, tested path.
|
|
100
|
-
|
|
101
|
-
This fallback is lower-reliability than running the script — flag anything you were unsure about.
|
|
102
|
-
|
|
103
|
-
## Mapping
|
|
104
|
-
|
|
105
|
-
The full per-element mapping (fidelity + whether the script or the model owns it) is `mapping-table.md` — it's the algorithm for the fallback and the regression contract, so it's kept there, not restated here.
|
|
106
|
-
|
|
107
76
|
## Scope limits (hard facts, verified against the CLI/engine code)
|
|
108
77
|
|
|
109
78
|
- Performance runs execute **one collection repeated across VUs**. Multiple thread groups cannot run concurrently in a single run.
|
|
110
79
|
- Runs are **time-based**, not iteration-based. JMeter loop counts become a duration.
|
|
111
80
|
- `--pass-if` is a **single, non-repeatable** condition. Extra assertions stay as `pm.test` in scripts.
|
|
112
|
-
- **No
|
|
81
|
+
- **No think time.** JMeter timers are not reproduced; each one is reported.
|
|
113
82
|
- Only **HTTP(S)** samplers import. JDBC/FTP/LDAP/Java/TCP/Debug/Dummy samplers are reported as skipped.
|