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