trickle-cli 0.1.0
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/dist/api-client.d.ts +208 -0
- package/dist/api-client.js +237 -0
- package/dist/commands/annotate.d.ts +6 -0
- package/dist/commands/annotate.js +433 -0
- package/dist/commands/audit.d.ts +7 -0
- package/dist/commands/audit.js +82 -0
- package/dist/commands/auto.d.ts +8 -0
- package/dist/commands/auto.js +268 -0
- package/dist/commands/capture.d.ts +14 -0
- package/dist/commands/capture.js +271 -0
- package/dist/commands/check.d.ts +6 -0
- package/dist/commands/check.js +408 -0
- package/dist/commands/codegen.d.ts +21 -0
- package/dist/commands/codegen.js +129 -0
- package/dist/commands/coverage.d.ts +13 -0
- package/dist/commands/coverage.js +126 -0
- package/dist/commands/dashboard.d.ts +1 -0
- package/dist/commands/dashboard.js +83 -0
- package/dist/commands/dev.d.ts +14 -0
- package/dist/commands/dev.js +319 -0
- package/dist/commands/diff.d.ts +7 -0
- package/dist/commands/diff.js +79 -0
- package/dist/commands/docs.d.ts +13 -0
- package/dist/commands/docs.js +383 -0
- package/dist/commands/errors.d.ts +7 -0
- package/dist/commands/errors.js +180 -0
- package/dist/commands/export.d.ts +18 -0
- package/dist/commands/export.js +238 -0
- package/dist/commands/functions.d.ts +6 -0
- package/dist/commands/functions.js +71 -0
- package/dist/commands/infer.d.ts +14 -0
- package/dist/commands/infer.js +275 -0
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.js +395 -0
- package/dist/commands/mock.d.ts +5 -0
- package/dist/commands/mock.js +232 -0
- package/dist/commands/openapi.d.ts +8 -0
- package/dist/commands/openapi.js +82 -0
- package/dist/commands/overview.d.ts +11 -0
- package/dist/commands/overview.js +266 -0
- package/dist/commands/pack.d.ts +11 -0
- package/dist/commands/pack.js +133 -0
- package/dist/commands/proxy.d.ts +13 -0
- package/dist/commands/proxy.js +312 -0
- package/dist/commands/replay.d.ts +14 -0
- package/dist/commands/replay.js +289 -0
- package/dist/commands/run.d.ts +17 -0
- package/dist/commands/run.js +997 -0
- package/dist/commands/sample.d.ts +13 -0
- package/dist/commands/sample.js +260 -0
- package/dist/commands/search.d.ts +5 -0
- package/dist/commands/search.js +80 -0
- package/dist/commands/stubs.d.ts +6 -0
- package/dist/commands/stubs.js +187 -0
- package/dist/commands/tail.d.ts +4 -0
- package/dist/commands/tail.js +76 -0
- package/dist/commands/test-gen.d.ts +13 -0
- package/dist/commands/test-gen.js +237 -0
- package/dist/commands/trace.d.ts +14 -0
- package/dist/commands/trace.js +417 -0
- package/dist/commands/types.d.ts +7 -0
- package/dist/commands/types.js +128 -0
- package/dist/commands/unpack.d.ts +11 -0
- package/dist/commands/unpack.js +166 -0
- package/dist/commands/validate.d.ts +13 -0
- package/dist/commands/validate.js +310 -0
- package/dist/commands/watch.d.ts +9 -0
- package/dist/commands/watch.js +267 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js +66 -0
- package/dist/formatters/diff-formatter.d.ts +5 -0
- package/dist/formatters/diff-formatter.js +43 -0
- package/dist/formatters/type-formatter.d.ts +22 -0
- package/dist/formatters/type-formatter.js +135 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +419 -0
- package/dist/local-codegen.d.ts +22 -0
- package/dist/local-codegen.js +762 -0
- package/dist/ui/badges.d.ts +16 -0
- package/dist/ui/badges.js +71 -0
- package/dist/ui/helpers.d.ts +13 -0
- package/dist/ui/helpers.js +85 -0
- package/package.json +23 -0
- package/src/api-client.ts +407 -0
- package/src/commands/annotate.ts +450 -0
- package/src/commands/audit.ts +103 -0
- package/src/commands/auto.ts +268 -0
- package/src/commands/capture.ts +257 -0
- package/src/commands/check.ts +437 -0
- package/src/commands/codegen.ts +128 -0
- package/src/commands/coverage.ts +170 -0
- package/src/commands/dashboard.ts +46 -0
- package/src/commands/dev.ts +323 -0
- package/src/commands/diff.ts +99 -0
- package/src/commands/docs.ts +392 -0
- package/src/commands/errors.ts +205 -0
- package/src/commands/export.ts +287 -0
- package/src/commands/functions.ts +81 -0
- package/src/commands/infer.ts +260 -0
- package/src/commands/init.ts +419 -0
- package/src/commands/mock.ts +220 -0
- package/src/commands/openapi.ts +53 -0
- package/src/commands/overview.ts +310 -0
- package/src/commands/pack.ts +139 -0
- package/src/commands/proxy.ts +314 -0
- package/src/commands/replay.ts +356 -0
- package/src/commands/run.ts +1190 -0
- package/src/commands/sample.ts +259 -0
- package/src/commands/search.ts +107 -0
- package/src/commands/stubs.ts +211 -0
- package/src/commands/tail.ts +94 -0
- package/src/commands/test-gen.ts +236 -0
- package/src/commands/trace.ts +440 -0
- package/src/commands/types.ts +161 -0
- package/src/commands/unpack.ts +179 -0
- package/src/commands/validate.ts +368 -0
- package/src/commands/watch.ts +277 -0
- package/src/config.ts +38 -0
- package/src/formatters/diff-formatter.ts +51 -0
- package/src/formatters/type-formatter.ts +161 -0
- package/src/index.ts +454 -0
- package/src/local-codegen.ts +859 -0
- package/src/ui/badges.ts +66 -0
- package/src/ui/helpers.ts +80 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import * as http from "http";
|
|
2
|
+
import * as https from "https";
|
|
3
|
+
import * as crypto from "crypto";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { getBackendUrl } from "../config";
|
|
6
|
+
|
|
7
|
+
export interface ProxyOptions {
|
|
8
|
+
target: string;
|
|
9
|
+
port?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface TypeNode {
|
|
13
|
+
kind: string;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `trickle proxy` — Transparent reverse proxy that captures API types.
|
|
19
|
+
*
|
|
20
|
+
* Sits between the frontend and backend, forwarding all requests while
|
|
21
|
+
* observing request/response shapes and sending type observations to
|
|
22
|
+
* the trickle backend. Works with any backend language or framework —
|
|
23
|
+
* no instrumentation needed.
|
|
24
|
+
*/
|
|
25
|
+
export async function proxyCommand(opts: ProxyOptions): Promise<void> {
|
|
26
|
+
const targetUrl = opts.target;
|
|
27
|
+
if (!targetUrl) {
|
|
28
|
+
console.error(chalk.red("\n Missing --target flag."));
|
|
29
|
+
console.error(chalk.gray(" Usage: trickle proxy --target http://localhost:3000\n"));
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let parsedTarget: URL;
|
|
34
|
+
try {
|
|
35
|
+
parsedTarget = new URL(targetUrl);
|
|
36
|
+
} catch {
|
|
37
|
+
console.error(chalk.red(`\n Invalid target URL: ${targetUrl}\n`));
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const port = parseInt(opts.port || "4000", 10);
|
|
42
|
+
const backendUrl = getBackendUrl();
|
|
43
|
+
|
|
44
|
+
// Check backend connectivity
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetch(`${backendUrl}/api/health`, { signal: AbortSignal.timeout(3000) });
|
|
47
|
+
if (!res.ok) throw new Error("not ok");
|
|
48
|
+
} catch {
|
|
49
|
+
console.error(chalk.red(`\n Cannot reach trickle backend at ${chalk.bold(backendUrl)}`));
|
|
50
|
+
console.error(chalk.gray(" Start the backend first.\n"));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let requestCount = 0;
|
|
55
|
+
let typesSent = 0;
|
|
56
|
+
|
|
57
|
+
const server = http.createServer(async (req, res) => {
|
|
58
|
+
const method = (req.method || "GET").toUpperCase();
|
|
59
|
+
const urlPath = req.url || "/";
|
|
60
|
+
|
|
61
|
+
// Read request body
|
|
62
|
+
const reqBody = await readBody(req);
|
|
63
|
+
let reqJson: unknown = undefined;
|
|
64
|
+
if (reqBody.length > 0) {
|
|
65
|
+
try {
|
|
66
|
+
reqJson = JSON.parse(reqBody.toString("utf-8"));
|
|
67
|
+
} catch {
|
|
68
|
+
// Not JSON — skip type capture for request body
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Forward to target
|
|
73
|
+
const targetReqUrl = new URL(urlPath, targetUrl);
|
|
74
|
+
const isHttps = parsedTarget.protocol === "https:";
|
|
75
|
+
const mod = isHttps ? https : http;
|
|
76
|
+
|
|
77
|
+
const proxyReq = mod.request(
|
|
78
|
+
targetReqUrl.toString(),
|
|
79
|
+
{
|
|
80
|
+
method,
|
|
81
|
+
headers: {
|
|
82
|
+
...req.headers,
|
|
83
|
+
host: parsedTarget.host,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
(proxyRes) => {
|
|
87
|
+
// Read response body
|
|
88
|
+
const chunks: Buffer[] = [];
|
|
89
|
+
proxyRes.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
90
|
+
proxyRes.on("end", () => {
|
|
91
|
+
const resBody = Buffer.concat(chunks);
|
|
92
|
+
|
|
93
|
+
// Forward response to client
|
|
94
|
+
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
|
95
|
+
res.end(resBody);
|
|
96
|
+
|
|
97
|
+
requestCount++;
|
|
98
|
+
|
|
99
|
+
// Parse response JSON for type capture
|
|
100
|
+
let resJson: unknown = undefined;
|
|
101
|
+
const contentType = proxyRes.headers["content-type"] || "";
|
|
102
|
+
if (contentType.includes("json") && resBody.length > 0) {
|
|
103
|
+
try {
|
|
104
|
+
resJson = JSON.parse(resBody.toString("utf-8"));
|
|
105
|
+
} catch {
|
|
106
|
+
// Not valid JSON
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Only capture types for JSON API-like routes
|
|
111
|
+
if (resJson !== undefined && isApiRoute(urlPath)) {
|
|
112
|
+
captureTypes(method, urlPath, reqJson, resJson, backendUrl).then((sent) => {
|
|
113
|
+
if (sent) typesSent++;
|
|
114
|
+
}).catch(() => {});
|
|
115
|
+
|
|
116
|
+
// Log
|
|
117
|
+
const status = proxyRes.statusCode || 200;
|
|
118
|
+
const statusColor = status < 400 ? chalk.green : chalk.red;
|
|
119
|
+
console.log(
|
|
120
|
+
chalk.gray(` ${chalk.bold(method)} ${urlPath} → `) +
|
|
121
|
+
statusColor(`${status}`) +
|
|
122
|
+
chalk.gray(` (${typesSent} types captured)`),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
proxyReq.on("error", (err) => {
|
|
130
|
+
console.error(chalk.red(` Proxy error: ${err.message}`));
|
|
131
|
+
res.writeHead(502);
|
|
132
|
+
res.end(JSON.stringify({ error: "Bad Gateway", message: err.message }));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (reqBody.length > 0) {
|
|
136
|
+
proxyReq.write(reqBody);
|
|
137
|
+
}
|
|
138
|
+
proxyReq.end();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
server.listen(port, () => {
|
|
142
|
+
console.log("");
|
|
143
|
+
console.log(chalk.bold(" trickle proxy"));
|
|
144
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
145
|
+
console.log(chalk.gray(` Proxy: http://localhost:${port}`));
|
|
146
|
+
console.log(chalk.gray(` Target: ${targetUrl}`));
|
|
147
|
+
console.log(chalk.gray(` Backend: ${backendUrl}`));
|
|
148
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
149
|
+
console.log(chalk.gray(" Point your frontend at the proxy URL."));
|
|
150
|
+
console.log(chalk.gray(" Press Ctrl+C to stop.\n"));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
process.on("SIGINT", () => {
|
|
154
|
+
console.log(chalk.gray(`\n Shutting down... (${requestCount} requests, ${typesSent} types captured)`));
|
|
155
|
+
server.close();
|
|
156
|
+
process.exit(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
process.on("SIGTERM", () => {
|
|
160
|
+
server.close();
|
|
161
|
+
process.exit(0);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readBody(stream: http.IncomingMessage): Promise<Buffer> {
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
const chunks: Buffer[] = [];
|
|
168
|
+
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
169
|
+
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
170
|
+
stream.on("error", () => resolve(Buffer.alloc(0)));
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Heuristic: only capture types for API-like routes (not static assets).
|
|
176
|
+
*/
|
|
177
|
+
function isApiRoute(urlPath: string): boolean {
|
|
178
|
+
const path = urlPath.split("?")[0];
|
|
179
|
+
// Skip obvious static assets
|
|
180
|
+
if (/\.(js|css|html|png|jpg|gif|svg|ico|woff|woff2|ttf|eot|map)$/i.test(path)) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
// Include /api/ routes always
|
|
184
|
+
if (path.includes("/api/")) return true;
|
|
185
|
+
// Include anything that doesn't look like a file
|
|
186
|
+
if (!path.includes(".")) return true;
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Infer a TypeNode from a JSON value.
|
|
192
|
+
*/
|
|
193
|
+
function jsonToTypeNode(value: unknown): TypeNode {
|
|
194
|
+
if (value === null) return { kind: "primitive", name: "null" };
|
|
195
|
+
if (value === undefined) return { kind: "primitive", name: "undefined" };
|
|
196
|
+
|
|
197
|
+
switch (typeof value) {
|
|
198
|
+
case "string": return { kind: "primitive", name: "string" };
|
|
199
|
+
case "number": return { kind: "primitive", name: "number" };
|
|
200
|
+
case "boolean": return { kind: "primitive", name: "boolean" };
|
|
201
|
+
case "bigint": return { kind: "primitive", name: "bigint" };
|
|
202
|
+
case "symbol": return { kind: "primitive", name: "symbol" };
|
|
203
|
+
case "function": return { kind: "function", params: [], returnType: { kind: "unknown" } };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (Array.isArray(value)) {
|
|
207
|
+
if (value.length === 0) return { kind: "array", element: { kind: "unknown" } };
|
|
208
|
+
// Infer element type from first element
|
|
209
|
+
const elementType = jsonToTypeNode(value[0]);
|
|
210
|
+
return { kind: "array", element: elementType };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Object
|
|
214
|
+
const obj = value as Record<string, unknown>;
|
|
215
|
+
const properties: Record<string, TypeNode> = {};
|
|
216
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
217
|
+
properties[key] = jsonToTypeNode(val);
|
|
218
|
+
}
|
|
219
|
+
return { kind: "object", properties };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Normalize URL path: replace dynamic segments like /users/123 with :param patterns.
|
|
224
|
+
* Uses heuristics: numeric segments and UUID-like segments become params.
|
|
225
|
+
*/
|
|
226
|
+
function normalizePath(urlPath: string): string {
|
|
227
|
+
const path = urlPath.split("?")[0];
|
|
228
|
+
const parts = path.split("/");
|
|
229
|
+
return parts
|
|
230
|
+
.map((part, i) => {
|
|
231
|
+
if (!part) return part;
|
|
232
|
+
// Numeric IDs
|
|
233
|
+
if (/^\d+$/.test(part)) return ":id";
|
|
234
|
+
// UUIDs
|
|
235
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(part)) return ":id";
|
|
236
|
+
// Short hex hashes
|
|
237
|
+
if (/^[0-9a-f]{16,}$/i.test(part) && i > 1) return ":id";
|
|
238
|
+
return part;
|
|
239
|
+
})
|
|
240
|
+
.join("/");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Compute a SHA-256 hash (16 hex chars) for type dedup.
|
|
245
|
+
*/
|
|
246
|
+
function computeTypeHash(argsType: TypeNode, returnType: TypeNode): string {
|
|
247
|
+
const data = JSON.stringify({ a: argsType, r: returnType });
|
|
248
|
+
return crypto.createHash("sha256").update(data).digest("hex").slice(0, 16);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Send captured types to the trickle backend.
|
|
253
|
+
*/
|
|
254
|
+
async function captureTypes(
|
|
255
|
+
method: string,
|
|
256
|
+
urlPath: string,
|
|
257
|
+
reqJson: unknown,
|
|
258
|
+
resJson: unknown,
|
|
259
|
+
backendUrl: string,
|
|
260
|
+
): Promise<boolean> {
|
|
261
|
+
const normalizedPath = normalizePath(urlPath);
|
|
262
|
+
const functionName = `${method} ${normalizedPath}`;
|
|
263
|
+
|
|
264
|
+
// Build argsType: { body, params, query }
|
|
265
|
+
const argsProperties: Record<string, TypeNode> = {};
|
|
266
|
+
|
|
267
|
+
if (reqJson !== undefined && reqJson !== null) {
|
|
268
|
+
argsProperties.body = jsonToTypeNode(reqJson);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Extract query params
|
|
272
|
+
const queryStart = urlPath.indexOf("?");
|
|
273
|
+
if (queryStart !== -1) {
|
|
274
|
+
const searchParams = new URLSearchParams(urlPath.slice(queryStart + 1));
|
|
275
|
+
const queryProps: Record<string, TypeNode> = {};
|
|
276
|
+
for (const [key] of searchParams) {
|
|
277
|
+
queryProps[key] = { kind: "primitive", name: "string" };
|
|
278
|
+
}
|
|
279
|
+
if (Object.keys(queryProps).length > 0) {
|
|
280
|
+
argsProperties.query = { kind: "object", properties: queryProps };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const argsType: TypeNode = Object.keys(argsProperties).length > 0
|
|
285
|
+
? { kind: "object", properties: argsProperties }
|
|
286
|
+
: { kind: "object", properties: {} };
|
|
287
|
+
|
|
288
|
+
const returnType = jsonToTypeNode(resJson);
|
|
289
|
+
const typeHash = computeTypeHash(argsType, returnType);
|
|
290
|
+
|
|
291
|
+
const payload = {
|
|
292
|
+
functionName,
|
|
293
|
+
module: "proxy",
|
|
294
|
+
language: "js",
|
|
295
|
+
environment: "development",
|
|
296
|
+
typeHash,
|
|
297
|
+
argsType,
|
|
298
|
+
returnType,
|
|
299
|
+
sampleInput: reqJson !== undefined ? (Object.keys(argsProperties).length > 0 ? argsProperties : undefined) : undefined,
|
|
300
|
+
sampleOutput: resJson,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const res = await fetch(`${backendUrl}/api/ingest`, {
|
|
305
|
+
method: "POST",
|
|
306
|
+
headers: { "Content-Type": "application/json" },
|
|
307
|
+
body: JSON.stringify(payload),
|
|
308
|
+
signal: AbortSignal.timeout(5000),
|
|
309
|
+
});
|
|
310
|
+
return res.ok;
|
|
311
|
+
} catch {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { fetchMockConfig, MockRoute } from "../api-client";
|
|
3
|
+
|
|
4
|
+
export interface ReplayOptions {
|
|
5
|
+
target?: string;
|
|
6
|
+
strict?: boolean;
|
|
7
|
+
json?: boolean;
|
|
8
|
+
failFast?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ReplayResult {
|
|
12
|
+
method: string;
|
|
13
|
+
path: string;
|
|
14
|
+
status: "pass" | "fail" | "error";
|
|
15
|
+
httpStatus?: number;
|
|
16
|
+
message?: string;
|
|
17
|
+
expectedKeys?: string[];
|
|
18
|
+
actualKeys?: string[];
|
|
19
|
+
durationMs: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `trickle replay` — Replay captured API requests as regression tests.
|
|
24
|
+
*
|
|
25
|
+
* Uses the sample inputs/outputs already captured by trickle to replay
|
|
26
|
+
* requests against a running server and verify response shapes match.
|
|
27
|
+
* Developers get free regression tests without writing any test code.
|
|
28
|
+
*/
|
|
29
|
+
export async function replayCommand(opts: ReplayOptions): Promise<void> {
|
|
30
|
+
const target = opts.target || "http://localhost:3000";
|
|
31
|
+
|
|
32
|
+
// Fetch observed routes
|
|
33
|
+
let routes: MockRoute[];
|
|
34
|
+
try {
|
|
35
|
+
const config = await fetchMockConfig();
|
|
36
|
+
routes = config.routes;
|
|
37
|
+
} catch {
|
|
38
|
+
console.error(chalk.red("\n Cannot connect to trickle backend."));
|
|
39
|
+
console.error(chalk.gray(" Is the backend running?\n"));
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (routes.length === 0) {
|
|
44
|
+
console.error(chalk.yellow("\n No observed routes to replay."));
|
|
45
|
+
console.error(chalk.gray(" Instrument your app and make some requests first.\n"));
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!opts.json) {
|
|
50
|
+
console.log("");
|
|
51
|
+
console.log(chalk.bold(" trickle replay"));
|
|
52
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
53
|
+
console.log(chalk.gray(` Target: ${target}`));
|
|
54
|
+
console.log(chalk.gray(` Routes: ${routes.length}`));
|
|
55
|
+
console.log(chalk.gray(` Mode: ${opts.strict ? "strict (exact values)" : "shape (structural match)"}`));
|
|
56
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
57
|
+
console.log("");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const results: ReplayResult[] = [];
|
|
61
|
+
|
|
62
|
+
for (const route of routes) {
|
|
63
|
+
const result = await replayRoute(route, target, opts.strict || false);
|
|
64
|
+
results.push(result);
|
|
65
|
+
|
|
66
|
+
if (!opts.json) {
|
|
67
|
+
const icon = result.status === "pass"
|
|
68
|
+
? chalk.green("✓")
|
|
69
|
+
: result.status === "fail"
|
|
70
|
+
? chalk.red("✗")
|
|
71
|
+
: chalk.yellow("!");
|
|
72
|
+
const statusStr = result.httpStatus ? chalk.gray(` [${result.httpStatus}]`) : "";
|
|
73
|
+
const timeStr = chalk.gray(` ${result.durationMs}ms`);
|
|
74
|
+
const msg = result.message ? chalk.gray(` — ${result.message}`) : "";
|
|
75
|
+
console.log(` ${icon} ${chalk.bold(route.method)} ${route.path}${statusStr}${timeStr}${msg}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (opts.failFast && result.status !== "pass") {
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Summary
|
|
84
|
+
const passed = results.filter((r) => r.status === "pass").length;
|
|
85
|
+
const failed = results.filter((r) => r.status === "fail").length;
|
|
86
|
+
const errors = results.filter((r) => r.status === "error").length;
|
|
87
|
+
|
|
88
|
+
if (opts.json) {
|
|
89
|
+
console.log(JSON.stringify({
|
|
90
|
+
target,
|
|
91
|
+
mode: opts.strict ? "strict" : "shape",
|
|
92
|
+
total: results.length,
|
|
93
|
+
passed,
|
|
94
|
+
failed,
|
|
95
|
+
errors,
|
|
96
|
+
results,
|
|
97
|
+
}, null, 2));
|
|
98
|
+
} else {
|
|
99
|
+
console.log("");
|
|
100
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
101
|
+
if (failed === 0 && errors === 0) {
|
|
102
|
+
console.log(chalk.green(` ${passed}/${results.length} passed`) + chalk.gray(` — all routes match`));
|
|
103
|
+
} else {
|
|
104
|
+
const parts: string[] = [];
|
|
105
|
+
if (passed > 0) parts.push(chalk.green(`${passed} passed`));
|
|
106
|
+
if (failed > 0) parts.push(chalk.red(`${failed} failed`));
|
|
107
|
+
if (errors > 0) parts.push(chalk.yellow(`${errors} errors`));
|
|
108
|
+
console.log(` ${parts.join(", ")} out of ${results.length} routes`);
|
|
109
|
+
}
|
|
110
|
+
console.log("");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (failed > 0 || errors > 0) {
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function replayRoute(
|
|
119
|
+
route: MockRoute,
|
|
120
|
+
target: string,
|
|
121
|
+
strict: boolean,
|
|
122
|
+
): Promise<ReplayResult> {
|
|
123
|
+
const { method, path: routePath } = route;
|
|
124
|
+
|
|
125
|
+
// Build URL — replace :param patterns with sample values if available
|
|
126
|
+
let url = routePath;
|
|
127
|
+
if (route.sampleInput && typeof route.sampleInput === "object") {
|
|
128
|
+
const input = route.sampleInput as Record<string, unknown>;
|
|
129
|
+
const params = input.params as Record<string, string> | undefined;
|
|
130
|
+
if (params) {
|
|
131
|
+
for (const [key, value] of Object.entries(params)) {
|
|
132
|
+
url = url.replace(`:${key}`, String(value));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Replace any remaining :params with "1" as fallback
|
|
137
|
+
url = url.replace(/:(\w+)/g, "1");
|
|
138
|
+
|
|
139
|
+
const fullUrl = `${target}${url}`;
|
|
140
|
+
const start = Date.now();
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
// Build request
|
|
144
|
+
const fetchOpts: RequestInit = { method };
|
|
145
|
+
const hasBody = ["POST", "PUT", "PATCH"].includes(method);
|
|
146
|
+
|
|
147
|
+
if (hasBody && route.sampleInput) {
|
|
148
|
+
const input = route.sampleInput as Record<string, unknown>;
|
|
149
|
+
const body = input.body || input;
|
|
150
|
+
if (body && typeof body === "object" && Object.keys(body as object).length > 0) {
|
|
151
|
+
fetchOpts.headers = { "Content-Type": "application/json" };
|
|
152
|
+
fetchOpts.body = JSON.stringify(body);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const res = await fetch(fullUrl, {
|
|
157
|
+
...fetchOpts,
|
|
158
|
+
signal: AbortSignal.timeout(10000),
|
|
159
|
+
});
|
|
160
|
+
const durationMs = Date.now() - start;
|
|
161
|
+
|
|
162
|
+
if (!res.ok) {
|
|
163
|
+
return {
|
|
164
|
+
method, path: routePath, status: "fail",
|
|
165
|
+
httpStatus: res.status,
|
|
166
|
+
message: `HTTP ${res.status}`,
|
|
167
|
+
durationMs,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Parse response
|
|
172
|
+
const contentType = res.headers.get("content-type") || "";
|
|
173
|
+
if (!contentType.includes("json")) {
|
|
174
|
+
return {
|
|
175
|
+
method, path: routePath, status: "pass",
|
|
176
|
+
httpStatus: res.status,
|
|
177
|
+
message: "non-JSON response",
|
|
178
|
+
durationMs,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const actual = await res.json();
|
|
183
|
+
|
|
184
|
+
if (!route.sampleOutput) {
|
|
185
|
+
return {
|
|
186
|
+
method, path: routePath, status: "pass",
|
|
187
|
+
httpStatus: res.status,
|
|
188
|
+
durationMs,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Compare
|
|
193
|
+
if (strict) {
|
|
194
|
+
return compareStrict(method, routePath, route.sampleOutput, actual, res.status, durationMs);
|
|
195
|
+
} else {
|
|
196
|
+
return compareShape(method, routePath, route.sampleOutput, actual, res.status, durationMs);
|
|
197
|
+
}
|
|
198
|
+
} catch (err: unknown) {
|
|
199
|
+
const durationMs = Date.now() - start;
|
|
200
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
201
|
+
return {
|
|
202
|
+
method, path: routePath, status: "error",
|
|
203
|
+
message: message.includes("ECONNREFUSED") ? "connection refused" : message,
|
|
204
|
+
durationMs,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Shape comparison: verify that the actual response has the same structure
|
|
211
|
+
* (same keys, same types) as the expected sample output.
|
|
212
|
+
*/
|
|
213
|
+
function compareShape(
|
|
214
|
+
method: string,
|
|
215
|
+
path: string,
|
|
216
|
+
expected: unknown,
|
|
217
|
+
actual: unknown,
|
|
218
|
+
httpStatus: number,
|
|
219
|
+
durationMs: number,
|
|
220
|
+
): ReplayResult {
|
|
221
|
+
const mismatches = findShapeMismatches(expected, actual, "");
|
|
222
|
+
|
|
223
|
+
if (mismatches.length === 0) {
|
|
224
|
+
return { method, path, status: "pass", httpStatus, durationMs };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
method, path, status: "fail", httpStatus,
|
|
229
|
+
message: mismatches[0],
|
|
230
|
+
durationMs,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function findShapeMismatches(expected: unknown, actual: unknown, prefix: string): string[] {
|
|
235
|
+
const mismatches: string[] = [];
|
|
236
|
+
|
|
237
|
+
if (expected === null || expected === undefined) {
|
|
238
|
+
return mismatches;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const expectedType = typeOf(expected);
|
|
242
|
+
const actualType = typeOf(actual);
|
|
243
|
+
|
|
244
|
+
if (expectedType !== actualType) {
|
|
245
|
+
mismatches.push(`${prefix || "root"}: expected ${expectedType}, got ${actualType}`);
|
|
246
|
+
return mismatches;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (expectedType === "object") {
|
|
250
|
+
const expectedObj = expected as Record<string, unknown>;
|
|
251
|
+
const actualObj = actual as Record<string, unknown>;
|
|
252
|
+
const expectedKeys = Object.keys(expectedObj).sort();
|
|
253
|
+
const actualKeys = Object.keys(actualObj).sort();
|
|
254
|
+
|
|
255
|
+
// Check for missing keys
|
|
256
|
+
for (const key of expectedKeys) {
|
|
257
|
+
if (!(key in actualObj)) {
|
|
258
|
+
mismatches.push(`${prefix ? prefix + "." : ""}${key}: missing`);
|
|
259
|
+
} else {
|
|
260
|
+
// Recurse (limit depth to avoid noise)
|
|
261
|
+
if (prefix.split(".").length < 3) {
|
|
262
|
+
mismatches.push(
|
|
263
|
+
...findShapeMismatches(expectedObj[key], actualObj[key], `${prefix ? prefix + "." : ""}${key}`),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (expectedType === "array") {
|
|
271
|
+
const expectedArr = expected as unknown[];
|
|
272
|
+
const actualArr = actual as unknown[];
|
|
273
|
+
if (expectedArr.length > 0 && actualArr.length > 0) {
|
|
274
|
+
mismatches.push(
|
|
275
|
+
...findShapeMismatches(expectedArr[0], actualArr[0], `${prefix}[0]`),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return mismatches;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function typeOf(value: unknown): string {
|
|
284
|
+
if (value === null) return "null";
|
|
285
|
+
if (Array.isArray(value)) return "array";
|
|
286
|
+
return typeof value;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Strict comparison: verify exact value match.
|
|
291
|
+
*/
|
|
292
|
+
function compareStrict(
|
|
293
|
+
method: string,
|
|
294
|
+
path: string,
|
|
295
|
+
expected: unknown,
|
|
296
|
+
actual: unknown,
|
|
297
|
+
httpStatus: number,
|
|
298
|
+
durationMs: number,
|
|
299
|
+
): ReplayResult {
|
|
300
|
+
// Deep compare, but be lenient with dynamic fields (ids, timestamps)
|
|
301
|
+
const mismatches = findValueMismatches(expected, actual, "");
|
|
302
|
+
|
|
303
|
+
if (mismatches.length === 0) {
|
|
304
|
+
return { method, path, status: "pass", httpStatus, durationMs };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
method, path, status: "fail", httpStatus,
|
|
309
|
+
message: mismatches[0],
|
|
310
|
+
durationMs,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function findValueMismatches(expected: unknown, actual: unknown, prefix: string): string[] {
|
|
315
|
+
const mismatches: string[] = [];
|
|
316
|
+
|
|
317
|
+
if (expected === null || expected === undefined) return mismatches;
|
|
318
|
+
|
|
319
|
+
const expectedType = typeOf(expected);
|
|
320
|
+
const actualType = typeOf(actual);
|
|
321
|
+
|
|
322
|
+
if (expectedType !== actualType) {
|
|
323
|
+
mismatches.push(`${prefix || "root"}: expected ${expectedType} got ${actualType}`);
|
|
324
|
+
return mismatches;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (expectedType === "object") {
|
|
328
|
+
const expectedObj = expected as Record<string, unknown>;
|
|
329
|
+
const actualObj = actual as Record<string, unknown>;
|
|
330
|
+
|
|
331
|
+
for (const key of Object.keys(expectedObj)) {
|
|
332
|
+
if (!(key in actualObj)) {
|
|
333
|
+
mismatches.push(`${prefix ? prefix + "." : ""}${key}: missing`);
|
|
334
|
+
} else if (prefix.split(".").length < 3) {
|
|
335
|
+
mismatches.push(
|
|
336
|
+
...findValueMismatches(expectedObj[key], actualObj[key], `${prefix ? prefix + "." : ""}${key}`),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
} else if (expectedType === "array") {
|
|
341
|
+
const expectedArr = expected as unknown[];
|
|
342
|
+
const actualArr = actual as unknown[];
|
|
343
|
+
if (expectedArr.length > 0 && actualArr.length > 0) {
|
|
344
|
+
mismatches.push(
|
|
345
|
+
...findValueMismatches(expectedArr[0], actualArr[0], `${prefix}[0]`),
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
} else {
|
|
349
|
+
// Primitive comparison
|
|
350
|
+
if (expected !== actual) {
|
|
351
|
+
mismatches.push(`${prefix || "root"}: expected ${JSON.stringify(expected)} got ${JSON.stringify(actual)}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return mismatches;
|
|
356
|
+
}
|