@testsmith/api-spector 0.2.3 → 0.2.4
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/bin/cli.js +38 -37
- package/out/main/chunks/import-C9qdkBCH.js +154 -0
- package/out/main/chunks/ipc-validate-CscN4HfG.js +94 -0
- package/out/main/chunks/{mock-server-Cx-xG4pJ.js → mock-server-DmdvwCgj.js} +4 -0
- package/out/main/chunks/{request-collection-DMVlm0PA.js → request-collection-DIsjTggj.js} +2 -0
- package/out/main/chunks/soap-handler-Cpj-JwyA.js +326 -0
- package/out/main/index.js +321 -86
- package/out/main/mock.js +1 -1
- package/out/main/runner.js +4 -3
- package/out/main/wsdl.js +174 -0
- package/out/preload/index.js +8 -0
- package/out/renderer/assets/{index-DdHHEKaz.js → index-BC1srylp.js} +2096 -1523
- package/out/renderer/assets/index-DfkLUeA1.css +2 -0
- package/out/renderer/index.html +2 -2
- package/package.json +6 -1
- package/out/renderer/assets/index-CxdQOFBM.css +0 -2
package/bin/cli.js
CHANGED
|
@@ -6,6 +6,23 @@ const { spawn } = require('child_process')
|
|
|
6
6
|
|
|
7
7
|
const [, , cmd = 'ui', ...rest] = process.argv
|
|
8
8
|
|
|
9
|
+
// ─── Command dispatch ────────────────────────────────────────────────────────
|
|
10
|
+
//
|
|
11
|
+
// Each entry maps a top-level command to the bundled JS file that handles it.
|
|
12
|
+
// `entrypoint: null` is reserved for `ui`, which spawns electron itself rather
|
|
13
|
+
// than a node script. Adding a new CLI surface is one line here + one entry in
|
|
14
|
+
// electron.vite.config.ts.
|
|
15
|
+
|
|
16
|
+
const COMMANDS = {
|
|
17
|
+
ui: { entrypoint: null, runner: 'electron' },
|
|
18
|
+
run: { entrypoint: 'runner.js', runner: 'node' },
|
|
19
|
+
mock: { entrypoint: 'mock.js', runner: 'node' },
|
|
20
|
+
record: { entrypoint: 'record.js', runner: 'node' },
|
|
21
|
+
agents: { entrypoint: 'agents.js', runner: 'node' },
|
|
22
|
+
contract: { entrypoint: 'contract.js',runner: 'node' },
|
|
23
|
+
wsdl: { entrypoint: 'wsdl.js', runner: 'node' },
|
|
24
|
+
}
|
|
25
|
+
|
|
9
26
|
function printHelp() {
|
|
10
27
|
console.log('')
|
|
11
28
|
console.log(' API Spector — local-first API testing tool')
|
|
@@ -16,6 +33,7 @@ function printHelp() {
|
|
|
16
33
|
console.log(' api-spector mock --workspace <path> Start mock servers from CLI')
|
|
17
34
|
console.log(' api-spector record --upstream <url> Record API traffic as mock stubs')
|
|
18
35
|
console.log(' api-spector contract list|run Manage & run pinned contract snapshots')
|
|
36
|
+
console.log(' api-spector wsdl describe|import-* Inspect a WSDL or import as collection/mock')
|
|
19
37
|
console.log('')
|
|
20
38
|
console.log(' Options:')
|
|
21
39
|
console.log(' api-spector agents init <name> Initialize AI agent files')
|
|
@@ -34,51 +52,34 @@ function printHelp() {
|
|
|
34
52
|
if (cmd === '--help' || cmd === '-h') {
|
|
35
53
|
printHelp()
|
|
36
54
|
process.exit(0)
|
|
37
|
-
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const command = COMMANDS[cmd]
|
|
58
|
+
if (!command) {
|
|
59
|
+
console.error(`API Spector — unknown command: "${cmd}"`)
|
|
60
|
+
printHelp()
|
|
61
|
+
process.exit(1)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ui: spawn electron with the app dir
|
|
65
|
+
if (command.runner === 'electron') {
|
|
38
66
|
const electron = require('electron')
|
|
39
67
|
const appDir = path.join(__dirname, '..')
|
|
68
|
+
// Forward the user's cwd so the main process can decide whether to open a
|
|
69
|
+
// workspace in this folder, or fall through to the welcome screen. Without
|
|
70
|
+
// this, the app would always auto-load the previously-opened workspace
|
|
71
|
+
// even when launched from an empty/different directory.
|
|
40
72
|
const proc = spawn(String(electron), [appDir, ...rest], {
|
|
41
73
|
stdio: 'inherit',
|
|
42
|
-
env: process.env,
|
|
43
|
-
})
|
|
44
|
-
proc.on('close', code => process.exit(code ?? 0))
|
|
45
|
-
} else if (cmd === 'run') {
|
|
46
|
-
const runnerPath = path.join(__dirname, '..', 'out', 'main', 'runner.js')
|
|
47
|
-
const proc = spawn(process.execPath, [runnerPath, ...rest], {
|
|
48
|
-
stdio: 'inherit',
|
|
49
|
-
env: process.env,
|
|
50
|
-
})
|
|
51
|
-
proc.on('close', code => process.exit(code ?? 0))
|
|
52
|
-
} else if (cmd === 'mock') {
|
|
53
|
-
const mockPath = path.join(__dirname, '..', 'out', 'main', 'mock.js')
|
|
54
|
-
const proc = spawn(process.execPath, [mockPath, ...rest], {
|
|
55
|
-
stdio: 'inherit',
|
|
56
|
-
env: process.env,
|
|
57
|
-
})
|
|
58
|
-
proc.on('close', code => process.exit(code ?? 0))
|
|
59
|
-
} else if (cmd === 'record') {
|
|
60
|
-
const recordPath = path.join(__dirname, '..', 'out', 'main', 'record.js')
|
|
61
|
-
const proc = spawn(process.execPath, [recordPath, ...rest], {
|
|
62
|
-
stdio: 'inherit',
|
|
63
|
-
env: process.env,
|
|
64
|
-
})
|
|
65
|
-
proc.on('close', code => process.exit(code ?? 0))
|
|
66
|
-
} else if (cmd === 'agents') {
|
|
67
|
-
const agentsPath = path.join(__dirname, '..', 'out', 'main', 'agents.js')
|
|
68
|
-
const proc = spawn(process.execPath, [agentsPath, ...rest], {
|
|
69
|
-
stdio: 'inherit',
|
|
70
|
-
env: process.env,
|
|
74
|
+
env: { ...process.env, API_SPECTOR_LAUNCH_CWD: process.cwd() },
|
|
71
75
|
})
|
|
72
76
|
proc.on('close', code => process.exit(code ?? 0))
|
|
73
|
-
} else
|
|
74
|
-
|
|
75
|
-
const
|
|
77
|
+
} else {
|
|
78
|
+
// node-runnable bundles in out/main/<entrypoint>
|
|
79
|
+
const target = path.join(__dirname, '..', 'out', 'main', command.entrypoint)
|
|
80
|
+
const proc = spawn(process.execPath, [target, ...rest], {
|
|
76
81
|
stdio: 'inherit',
|
|
77
82
|
env: process.env,
|
|
78
83
|
})
|
|
79
84
|
proc.on('close', code => process.exit(code ?? 0))
|
|
80
|
-
} else {
|
|
81
|
-
console.error(`API Spector — unknown command: "${cmd}"`)
|
|
82
|
-
printHelp()
|
|
83
|
-
process.exit(1)
|
|
84
85
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const uuid = require("uuid");
|
|
4
|
+
const soapHandler = require("./soap-handler-Cpj-JwyA.js");
|
|
5
|
+
require("https");
|
|
6
|
+
require("http");
|
|
7
|
+
require("@xmldom/xmldom");
|
|
8
|
+
const SOAP_11_CONTENT_TYPE = "text/xml; charset=utf-8";
|
|
9
|
+
const SOAP_12_CONTENT_TYPE = "application/soap+xml; charset=utf-8";
|
|
10
|
+
function contentTypeForSoap(version) {
|
|
11
|
+
return version === "1.2" ? SOAP_12_CONTENT_TYPE : SOAP_11_CONTENT_TYPE;
|
|
12
|
+
}
|
|
13
|
+
function withContentType(headers, value) {
|
|
14
|
+
const idx = headers.findIndex((h) => h.key.toLowerCase() === "content-type");
|
|
15
|
+
const next = { key: "Content-Type", value, enabled: true };
|
|
16
|
+
if (idx === -1) return [...headers, next];
|
|
17
|
+
return headers.map((h, i) => i === idx ? { ...h, value, enabled: true } : h);
|
|
18
|
+
}
|
|
19
|
+
function buildRequestsFromWsdl(parsed) {
|
|
20
|
+
const out = [];
|
|
21
|
+
const byName = /* @__PURE__ */ new Map();
|
|
22
|
+
for (const op of parsed.operations) {
|
|
23
|
+
const existing = byName.get(op.name);
|
|
24
|
+
if (!existing || existing.soapVersion === "1.2" && op.soapVersion === "1.1") byName.set(op.name, op);
|
|
25
|
+
}
|
|
26
|
+
for (const op of byName.values()) {
|
|
27
|
+
const ct = contentTypeForSoap(op.soapVersion);
|
|
28
|
+
const req = {
|
|
29
|
+
id: uuid.v4(),
|
|
30
|
+
name: op.name,
|
|
31
|
+
method: "POST",
|
|
32
|
+
url: op.endpoint ?? "",
|
|
33
|
+
headers: withContentType([], ct),
|
|
34
|
+
params: [],
|
|
35
|
+
auth: { type: "none" },
|
|
36
|
+
protocol: "soap",
|
|
37
|
+
body: {
|
|
38
|
+
mode: "soap",
|
|
39
|
+
soap: {
|
|
40
|
+
wsdlUrl: "",
|
|
41
|
+
operationName: op.name,
|
|
42
|
+
soapAction: op.soapAction ?? "",
|
|
43
|
+
envelope: op.inputTemplate
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
description: op.soapAction ? `SOAPAction: ${op.soapAction}` : void 0
|
|
47
|
+
};
|
|
48
|
+
out.push(req);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
function buildCollectionFromWsdl(name, parsed) {
|
|
53
|
+
const requests = buildRequestsFromWsdl(parsed);
|
|
54
|
+
const requestMap = {};
|
|
55
|
+
for (const r of requests) requestMap[r.id] = r;
|
|
56
|
+
const collection = {
|
|
57
|
+
version: "1.0",
|
|
58
|
+
id: uuid.v4(),
|
|
59
|
+
name,
|
|
60
|
+
description: `Imported from WSDL — ${parsed.endpoints[0]?.address ?? parsed.targetNamespace}`,
|
|
61
|
+
rootFolder: {
|
|
62
|
+
id: uuid.v4(),
|
|
63
|
+
name: "root",
|
|
64
|
+
description: "",
|
|
65
|
+
folders: [],
|
|
66
|
+
requestIds: requests.map((r) => r.id)
|
|
67
|
+
},
|
|
68
|
+
requests: requestMap
|
|
69
|
+
};
|
|
70
|
+
return { collection, requestIds: requests.map((r) => r.id) };
|
|
71
|
+
}
|
|
72
|
+
function pathFromUrl(url) {
|
|
73
|
+
try {
|
|
74
|
+
return new URL(url).pathname || "/";
|
|
75
|
+
} catch {
|
|
76
|
+
return "/";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function pickFreePort(existing) {
|
|
80
|
+
let p = 3900;
|
|
81
|
+
while (existing.includes(p)) p++;
|
|
82
|
+
return p;
|
|
83
|
+
}
|
|
84
|
+
function buildMockFromWsdl(name, parsed, existingPorts = []) {
|
|
85
|
+
const opMap = {};
|
|
86
|
+
const seen = /* @__PURE__ */ new Set();
|
|
87
|
+
for (const op of parsed.operations) {
|
|
88
|
+
if (seen.has(op.name)) continue;
|
|
89
|
+
seen.add(op.name);
|
|
90
|
+
opMap[op.name] = soapHandler.buildResponseEnvelope(op.name, parsed.targetNamespace, op.soapVersion);
|
|
91
|
+
}
|
|
92
|
+
const pathsByVersion = /* @__PURE__ */ new Map();
|
|
93
|
+
for (const ep of parsed.endpoints) {
|
|
94
|
+
pathsByVersion.set(pathFromUrl(ep.address), ep.soapVersion);
|
|
95
|
+
}
|
|
96
|
+
if (pathsByVersion.size === 0) pathsByVersion.set("/", "1.1");
|
|
97
|
+
const routes = [];
|
|
98
|
+
for (const [routePath, version] of pathsByVersion.entries()) {
|
|
99
|
+
routes.push({
|
|
100
|
+
id: uuid.v4(),
|
|
101
|
+
method: "POST",
|
|
102
|
+
path: routePath,
|
|
103
|
+
statusCode: 200,
|
|
104
|
+
headers: { "Content-Type": contentTypeForSoap(version) },
|
|
105
|
+
body: "",
|
|
106
|
+
description: `SOAP ${version} — dispatched per operation`,
|
|
107
|
+
script: soapHandler.buildMockDispatchScript(),
|
|
108
|
+
// Externalized so workspace JSON stays compact: the dispatch script reads
|
|
109
|
+
// these via the script-runner's `metadata` context binding instead of
|
|
110
|
+
// baking each envelope as a JS string literal.
|
|
111
|
+
metadata: { soapEnvelopes: opMap, soapVersion: version }
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
version: "1.0",
|
|
116
|
+
id: uuid.v4(),
|
|
117
|
+
name,
|
|
118
|
+
port: pickFreePort(existingPorts),
|
|
119
|
+
routes
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function importWsdl(wsdlText, opts = {}) {
|
|
123
|
+
const parsed = soapHandler.parseWsdl(wsdlText);
|
|
124
|
+
const baseName = opts.name?.trim() || (() => {
|
|
125
|
+
try {
|
|
126
|
+
return new URL(parsed.endpoints[0]?.address ?? "").hostname || "WSDL service";
|
|
127
|
+
} catch {
|
|
128
|
+
return "WSDL service";
|
|
129
|
+
}
|
|
130
|
+
})();
|
|
131
|
+
const { collection } = buildCollectionFromWsdl(baseName, parsed);
|
|
132
|
+
const mock = buildMockFromWsdl(`${baseName} (mock)`, parsed, opts.existingMockPorts ?? []);
|
|
133
|
+
return { parsed, collection, mock };
|
|
134
|
+
}
|
|
135
|
+
function defaultCollectionRelPath(ws, collection) {
|
|
136
|
+
const safe = collection.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "wsdl";
|
|
137
|
+
let i = 0;
|
|
138
|
+
let candidate = `collections/${safe}.json`;
|
|
139
|
+
const existing = new Set(ws.collections);
|
|
140
|
+
while (existing.has(candidate)) {
|
|
141
|
+
i++;
|
|
142
|
+
candidate = `collections/${safe}-${i}.json`;
|
|
143
|
+
}
|
|
144
|
+
return candidate;
|
|
145
|
+
}
|
|
146
|
+
function defaultMockRelPath(mock) {
|
|
147
|
+
return `mocks/${mock.id}.mock.json`;
|
|
148
|
+
}
|
|
149
|
+
exports.buildCollectionFromWsdl = buildCollectionFromWsdl;
|
|
150
|
+
exports.buildMockFromWsdl = buildMockFromWsdl;
|
|
151
|
+
exports.buildRequestsFromWsdl = buildRequestsFromWsdl;
|
|
152
|
+
exports.defaultCollectionRelPath = defaultCollectionRelPath;
|
|
153
|
+
exports.defaultMockRelPath = defaultMockRelPath;
|
|
154
|
+
exports.importWsdl = importWsdl;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const Ajv = require("ajv");
|
|
4
|
+
const ajv = new Ajv({
|
|
5
|
+
allErrors: true,
|
|
6
|
+
strict: false,
|
|
7
|
+
coerceTypes: false,
|
|
8
|
+
allowUnionTypes: true
|
|
9
|
+
});
|
|
10
|
+
function compile(schema) {
|
|
11
|
+
const fn = ajv.compile(schema);
|
|
12
|
+
return function validate(data) {
|
|
13
|
+
if (!fn(data)) {
|
|
14
|
+
const err = (fn.errors ?? []).map((e) => `${e.instancePath || "<root>"} ${e.message ?? "invalid"}`).join("; ");
|
|
15
|
+
throw new Error(`Invalid IPC payload: ${err || "unknown"}`);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const apiRequestSchema = {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
id: { type: "string" },
|
|
23
|
+
name: { type: "string" },
|
|
24
|
+
method: { type: "string" },
|
|
25
|
+
url: { type: "string" },
|
|
26
|
+
headers: { type: "array" },
|
|
27
|
+
params: { type: "array" },
|
|
28
|
+
auth: {
|
|
29
|
+
type: "object",
|
|
30
|
+
properties: { type: { type: "string" } },
|
|
31
|
+
required: ["type"],
|
|
32
|
+
additionalProperties: true
|
|
33
|
+
},
|
|
34
|
+
body: {
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: { mode: { type: "string" } },
|
|
37
|
+
required: ["mode"],
|
|
38
|
+
additionalProperties: true
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
required: ["id", "name", "method", "url", "headers", "params"],
|
|
42
|
+
additionalProperties: true
|
|
43
|
+
};
|
|
44
|
+
const stringMap = {
|
|
45
|
+
type: "object",
|
|
46
|
+
additionalProperties: { type: "string" }
|
|
47
|
+
};
|
|
48
|
+
const validateSendRequestPayload = compile({
|
|
49
|
+
type: "object",
|
|
50
|
+
properties: {
|
|
51
|
+
request: apiRequestSchema,
|
|
52
|
+
collectionVars: stringMap,
|
|
53
|
+
globals: stringMap,
|
|
54
|
+
piiMaskPatterns: { type: "array", items: { type: "string" } }
|
|
55
|
+
// `environment`, `proxy`, `tls` are accepted as anything — they're
|
|
56
|
+
// covered by the IPC handler's own type-narrow + defaults.
|
|
57
|
+
},
|
|
58
|
+
required: ["request"],
|
|
59
|
+
additionalProperties: true
|
|
60
|
+
});
|
|
61
|
+
const validateContractRunPayload = compile({
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
mode: { type: "string", enum: ["consumer", "provider", "bidirectional"] },
|
|
65
|
+
requests: { type: "array", items: apiRequestSchema },
|
|
66
|
+
envVars: stringMap,
|
|
67
|
+
collectionVars: stringMap,
|
|
68
|
+
specUrl: { type: "string" },
|
|
69
|
+
specPath: { type: "string" },
|
|
70
|
+
specSnapshotRelPath: { type: "string" },
|
|
71
|
+
requestBaseUrl: { type: "string" }
|
|
72
|
+
},
|
|
73
|
+
required: ["mode", "requests"],
|
|
74
|
+
additionalProperties: true
|
|
75
|
+
});
|
|
76
|
+
const validateWsdlFetchUrl = (url) => {
|
|
77
|
+
if (typeof url !== "string" || !url.trim()) throw new Error("Invalid IPC payload: url must be a non-empty string");
|
|
78
|
+
if (!/^https?:\/\//i.test(url)) throw new Error("Invalid IPC payload: url must start with http:// or https://");
|
|
79
|
+
};
|
|
80
|
+
const validateWsdlImport = compile({
|
|
81
|
+
type: "object",
|
|
82
|
+
properties: {
|
|
83
|
+
url: { type: "string" },
|
|
84
|
+
xml: { type: "string" },
|
|
85
|
+
name: { type: "string" },
|
|
86
|
+
extraHeaders: stringMap,
|
|
87
|
+
existingMockPorts: { type: "array", items: { type: "number" } }
|
|
88
|
+
},
|
|
89
|
+
additionalProperties: true
|
|
90
|
+
});
|
|
91
|
+
exports.validateContractRunPayload = validateContractRunPayload;
|
|
92
|
+
exports.validateSendRequestPayload = validateSendRequestPayload;
|
|
93
|
+
exports.validateWsdlFetchUrl = validateWsdlFetchUrl;
|
|
94
|
+
exports.validateWsdlImport = validateWsdlImport;
|
|
@@ -169,6 +169,10 @@ async function handleRequest(serverId, req, res, reqStart) {
|
|
|
169
169
|
vm__namespace.runInNewContext(route.script, {
|
|
170
170
|
request: requestCtx,
|
|
171
171
|
response: responseDraft,
|
|
172
|
+
// Free-form per-route data the importer/user can stash (e.g. SOAP
|
|
173
|
+
// envelopes by operation, lookup tables, fixtures). Frozen so a script
|
|
174
|
+
// can't mutate it across calls.
|
|
175
|
+
metadata: route.metadata ? Object.freeze({ ...route.metadata }) : {},
|
|
172
176
|
faker: faker2,
|
|
173
177
|
dayjs,
|
|
174
178
|
console: { log: (...args) => console.log("[mock-script]", ...args) }
|
|
@@ -32,6 +32,7 @@ const jsonpathPlus = require("jsonpath-plus");
|
|
|
32
32
|
const xmldom = require("@xmldom/xmldom");
|
|
33
33
|
const path = require("path");
|
|
34
34
|
const Ajv = require("ajv");
|
|
35
|
+
const ipcValidate = require("./ipc-validate-CscN4HfG.js");
|
|
35
36
|
function _interopNamespaceDefault(e) {
|
|
36
37
|
const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
|
|
37
38
|
if (e) {
|
|
@@ -642,6 +643,7 @@ async function buildDispatcher(proxy, tls) {
|
|
|
642
643
|
}
|
|
643
644
|
function registerRequestHandler(ipc) {
|
|
644
645
|
ipc.handle("request:send", async (_e, payload) => {
|
|
646
|
+
ipcValidate.validateSendRequestPayload(payload);
|
|
645
647
|
const {
|
|
646
648
|
request: req,
|
|
647
649
|
environment,
|