kiroo 0.7.4 â 0.9.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/README.md +368 -258
- package/bin/kiroo.js +361 -267
- package/package.json +4 -1
- package/src/analyze.js +550 -0
- package/src/config.js +109 -0
- package/src/deterministic.js +22 -0
- package/src/edit.js +85 -0
- package/src/env.js +31 -3
- package/src/executor.js +17 -0
- package/src/export.js +503 -0
- package/src/init.js +80 -48
- package/src/lingo.js +32 -0
- package/src/proxy.js +120 -0
- package/src/replay.js +5 -4
- package/src/sanitizer.js +100 -0
- package/src/snapshot.js +156 -34
- package/src/storage.js +223 -142
package/src/init.js
CHANGED
|
@@ -1,48 +1,80 @@
|
|
|
1
|
-
import chalk from 'chalk';
|
|
2
|
-
import inquirer from 'inquirer';
|
|
3
|
-
import {
|
|
4
|
-
import { ensureKirooDir } from './storage.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
console.log(
|
|
9
|
-
console.log(chalk.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { ensureKirooDir, loadEnv, saveEnv } from './storage.js';
|
|
5
|
+
import { saveKirooConfig } from './config.js';
|
|
6
|
+
|
|
7
|
+
export async function initProject() {
|
|
8
|
+
console.log('');
|
|
9
|
+
console.log(chalk.cyan.bold(' đ Welcome to Kiroo'));
|
|
10
|
+
console.log(chalk.gray(' Git for API interactions\n'));
|
|
11
|
+
|
|
12
|
+
if (existsSync('.kiroo')) {
|
|
13
|
+
console.log(chalk.yellow(' â ī¸ Kiroo is already initialized in this directory.\n'));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const answers = await inquirer.prompt([
|
|
18
|
+
{
|
|
19
|
+
type: 'input',
|
|
20
|
+
name: 'projectName',
|
|
21
|
+
message: 'Project name:',
|
|
22
|
+
default: 'my-api-project',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
type: 'input',
|
|
26
|
+
name: 'baseUrl',
|
|
27
|
+
message: 'Base URL (optional):',
|
|
28
|
+
default: '',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
type: 'password',
|
|
32
|
+
name: 'groqApiKey',
|
|
33
|
+
message: 'Groq API Key (optional, hidden):',
|
|
34
|
+
default: '',
|
|
35
|
+
mask: '*',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
type: 'password',
|
|
39
|
+
name: 'lingoApiKey',
|
|
40
|
+
message: 'Lingo.dev API Key (optional, hidden):',
|
|
41
|
+
default: '',
|
|
42
|
+
mask: '*',
|
|
43
|
+
},
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
ensureKirooDir();
|
|
47
|
+
|
|
48
|
+
const config = {
|
|
49
|
+
projectName: answers.projectName,
|
|
50
|
+
baseUrl: answers.baseUrl,
|
|
51
|
+
createdAt: new Date().toISOString(),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
saveKirooConfig(config);
|
|
55
|
+
|
|
56
|
+
const envData = loadEnv();
|
|
57
|
+
const current = envData.current || 'default';
|
|
58
|
+
if (!envData.environments[current]) {
|
|
59
|
+
envData.environments[current] = {};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (answers.baseUrl) {
|
|
63
|
+
envData.environments[current].baseUrl = answers.baseUrl;
|
|
64
|
+
}
|
|
65
|
+
if (answers.groqApiKey) {
|
|
66
|
+
envData.environments[current].GROQ_API_KEY = answers.groqApiKey;
|
|
67
|
+
}
|
|
68
|
+
if (answers.lingoApiKey) {
|
|
69
|
+
envData.environments[current].LINGODOTDEV_API_KEY = answers.lingoApiKey;
|
|
70
|
+
}
|
|
71
|
+
saveEnv(envData);
|
|
72
|
+
|
|
73
|
+
console.log('');
|
|
74
|
+
console.log(chalk.green(' â
Kiroo initialized successfully!'));
|
|
75
|
+
console.log('');
|
|
76
|
+
console.log(chalk.gray(' Next steps:'));
|
|
77
|
+
console.log(chalk.white(' kiroo POST https://api.example.com/login email=test@test.com'));
|
|
78
|
+
console.log(chalk.white(' kiroo list'));
|
|
79
|
+
console.log(chalk.white(' kiroo snapshot save initial\n'));
|
|
80
|
+
}
|
package/src/lingo.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { LingoDotDevEngine } from "lingo.dev/sdk";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { getEnvVar } from "./env.js";
|
|
4
|
+
|
|
5
|
+
function getLingoEngine() {
|
|
6
|
+
const apiKey = getEnvVar('LINGODOTDEV_API_KEY') || getEnvVar('LINGO_API_KEY');
|
|
7
|
+
|
|
8
|
+
if (!apiKey) {
|
|
9
|
+
console.log(chalk.yellow(`\n â ī¸ Lingo API key not found in .kiroo/env.json.`));
|
|
10
|
+
console.log(chalk.gray(` Run 'kiroo env set LINGODOTDEV_API_KEY <your_key>' or re-run 'kiroo init'.\n`));
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return new LingoDotDevEngine({ apiKey });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function translateText(text, targetLang) {
|
|
18
|
+
const engine = getLingoEngine();
|
|
19
|
+
if (!engine) return text;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const result = await engine.localizeText(text, {
|
|
23
|
+
sourceLocale: 'en',
|
|
24
|
+
targetLocale: targetLang,
|
|
25
|
+
fast: true
|
|
26
|
+
});
|
|
27
|
+
return result;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.log(chalk.red(`\n â ī¸ Translation failed: ${error.message}`));
|
|
30
|
+
return text;
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/proxy.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import httpProxy from 'http-proxy';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { URL } from 'url';
|
|
5
|
+
import stream from 'stream';
|
|
6
|
+
import { saveInteraction } from './storage.js';
|
|
7
|
+
|
|
8
|
+
export async function runProxy(targetHost, options = {}) {
|
|
9
|
+
const port = parseInt(options.port, 10) || 8080;
|
|
10
|
+
|
|
11
|
+
if (isNaN(port) || port <= 0 || port > 65535) {
|
|
12
|
+
console.error(chalk.red('\n â Invalid port provided.'), options.port);
|
|
13
|
+
console.log(chalk.gray(' Port must be a number between 1 and 65535.\n'));
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Validate target URL
|
|
18
|
+
try {
|
|
19
|
+
new URL(targetHost);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
console.error(chalk.red('\n â Invalid target URL provided.'), targetHost);
|
|
22
|
+
console.log(chalk.gray(' Example: kiroo proxy --target http://localhost:3000\n'));
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const proxy = httpProxy.createProxyServer({
|
|
27
|
+
target: targetHost,
|
|
28
|
+
changeOrigin: true,
|
|
29
|
+
secure: false,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Handle Proxy Errors
|
|
33
|
+
proxy.on('error', (err, req, res) => {
|
|
34
|
+
let errorMsg = err.message;
|
|
35
|
+
if (err.code === 'ECONNREFUSED') {
|
|
36
|
+
errorMsg = `Connection Refused. Is your backend server running on ${targetHost}?`;
|
|
37
|
+
} else if (err.code === 'ENOTFOUND') {
|
|
38
|
+
errorMsg = `Target host not found: ${targetHost}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.error(chalk.red(`\n â Proxy error for ${req.method} ${req.url}`));
|
|
42
|
+
console.error(chalk.yellow(` Error: ${errorMsg} (${err.code || 'UNKNOWN'})\n`));
|
|
43
|
+
|
|
44
|
+
if (!res.headersSent && res.writeHead) {
|
|
45
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
46
|
+
}
|
|
47
|
+
if (res.end) {
|
|
48
|
+
res.end(JSON.stringify({ error: 'Proxy Error', message: errorMsg, code: err.code }));
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Intercept Response to save interaction
|
|
53
|
+
proxy.on('proxyRes', (proxyRes, req, res) => {
|
|
54
|
+
let responseBody = '';
|
|
55
|
+
proxyRes.on('data', (chunk) => {
|
|
56
|
+
responseBody += chunk.toString('utf8');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
proxyRes.on('end', () => {
|
|
60
|
+
const duration = Date.now() - req.kirooStartTime;
|
|
61
|
+
const fullUrl = new URL(req.url, targetHost).toString();
|
|
62
|
+
|
|
63
|
+
let parsedReqBody = req.kirooBodyData;
|
|
64
|
+
if (req.kirooBodyData) {
|
|
65
|
+
try { parsedReqBody = JSON.parse(req.kirooBodyData); } catch (e) { }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let parsedResBody = responseBody;
|
|
69
|
+
if (responseBody) {
|
|
70
|
+
try { parsedResBody = JSON.parse(responseBody); } catch (e) { }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Only save if it's not a CORS preflight with no content
|
|
74
|
+
if (req.method !== 'OPTIONS' || proxyRes.statusCode !== 204) {
|
|
75
|
+
saveInteraction({
|
|
76
|
+
method: req.method,
|
|
77
|
+
url: fullUrl,
|
|
78
|
+
headers: req.headers,
|
|
79
|
+
body: parsedReqBody || undefined,
|
|
80
|
+
response: {
|
|
81
|
+
status: proxyRes.statusCode,
|
|
82
|
+
headers: proxyRes.headers,
|
|
83
|
+
body: parsedResBody || undefined
|
|
84
|
+
},
|
|
85
|
+
duration: duration
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const statusColor = proxyRes.statusCode >= 400 ? chalk.red : chalk.green;
|
|
89
|
+
console.log(` ${statusColor(req.method)} ${chalk.dim(fullUrl)} ${statusColor(proxyRes.statusCode)} - ${duration}ms`);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const server = http.createServer((req, res) => {
|
|
95
|
+
req.kirooStartTime = Date.now();
|
|
96
|
+
let bodyChunks = [];
|
|
97
|
+
|
|
98
|
+
req.on('data', (chunk) => {
|
|
99
|
+
bodyChunks.push(chunk);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
req.on('end', () => {
|
|
103
|
+
const bodyBuffer = Buffer.concat(bodyChunks);
|
|
104
|
+
req.kirooBodyData = bodyBuffer.toString('utf8');
|
|
105
|
+
|
|
106
|
+
// We must re-stream the body because the original req stream is exhausted
|
|
107
|
+
const bufferStream = new stream.PassThrough();
|
|
108
|
+
bufferStream.end(bodyBuffer);
|
|
109
|
+
|
|
110
|
+
proxy.web(req, res, { buffer: bufferStream });
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
server.listen(port, () => {
|
|
115
|
+
console.log(chalk.cyan(`\n đĄ Kiroo Proxy Started`));
|
|
116
|
+
console.log(chalk.white(` Listening on : http://localhost:${port}`));
|
|
117
|
+
console.log(chalk.white(` Forwarding to: ${targetHost}`));
|
|
118
|
+
console.log(chalk.gray(`\n (Press Ctrl+C to stop recording)\n`));
|
|
119
|
+
});
|
|
120
|
+
}
|
package/src/replay.js
CHANGED
|
@@ -11,7 +11,7 @@ export async function listInteractions(options) {
|
|
|
11
11
|
|
|
12
12
|
// Apply Filters
|
|
13
13
|
if (options.date) {
|
|
14
|
-
|
|
14
|
+
interactions = interactions.filter(int => int.id.startsWith(options.date));
|
|
15
15
|
}
|
|
16
16
|
if (options.url) {
|
|
17
17
|
interactions = interactions.filter(int => int.request.url.toLowerCase().includes(options.url.toLowerCase()));
|
|
@@ -42,12 +42,13 @@ export async function listInteractions(options) {
|
|
|
42
42
|
? chalk.red
|
|
43
43
|
: chalk.yellow;
|
|
44
44
|
|
|
45
|
+
const url = int.request.url || 'N/A';
|
|
45
46
|
table.push([
|
|
46
47
|
chalk.white(int.id),
|
|
47
|
-
chalk.white(int.request.method),
|
|
48
|
-
chalk.gray(
|
|
48
|
+
chalk.white(int.request.method || '???'),
|
|
49
|
+
chalk.gray(url.substring(0, 42) + (url.length > 42 ? '...' : '')),
|
|
49
50
|
statusColor(int.response.status),
|
|
50
|
-
chalk.gray(int.metadata.duration_ms + 'ms'),
|
|
51
|
+
chalk.gray((int.metadata.duration_ms || 0) + 'ms'),
|
|
51
52
|
]);
|
|
52
53
|
});
|
|
53
54
|
|
package/src/sanitizer.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const REDACTED = '<REDACTED>';
|
|
2
|
+
|
|
3
|
+
const SENSITIVE_KEY_PATTERN =
|
|
4
|
+
/authorization|cookie|set-cookie|token|secret|password|passwd|pwd|api[-_]?key|x-api-key|client[-_]?secret|session|jwt|access[-_]?token|refresh[-_]?token/i;
|
|
5
|
+
|
|
6
|
+
function redactSensitiveString(value, redactedValue = REDACTED) {
|
|
7
|
+
if (typeof value !== 'string') {
|
|
8
|
+
return redactedValue;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (/^bearer\s+/i.test(value)) {
|
|
12
|
+
return `Bearer ${redactedValue}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (/^basic\s+/i.test(value)) {
|
|
16
|
+
return `Basic ${redactedValue}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return redactedValue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isSensitiveKey(key, sensitiveKeyPattern = SENSITIVE_KEY_PATTERN) {
|
|
23
|
+
return sensitiveKeyPattern.test(String(key || ''));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function redactSensitiveInUrl(urlValue, options = {}) {
|
|
27
|
+
const redactedValue = options.redactedValue || REDACTED;
|
|
28
|
+
const sensitiveKeyPattern = options.sensitiveKeyPattern || SENSITIVE_KEY_PATTERN;
|
|
29
|
+
|
|
30
|
+
if (typeof urlValue !== 'string' || !urlValue.includes('?')) {
|
|
31
|
+
return urlValue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const sanitizeParams = (url) => {
|
|
35
|
+
for (const key of Array.from(url.searchParams.keys())) {
|
|
36
|
+
if (isSensitiveKey(key, sensitiveKeyPattern)) {
|
|
37
|
+
url.searchParams.set(key, redactedValue);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return url.toString();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
return sanitizeParams(new URL(urlValue));
|
|
45
|
+
} catch {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = new URL(urlValue, 'http://kiroo.local');
|
|
48
|
+
sanitizeParams(parsed);
|
|
49
|
+
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
50
|
+
} catch {
|
|
51
|
+
return urlValue;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sanitizeValue(value, currentKey = '', options = {}) {
|
|
57
|
+
const redactedValue = options.redactedValue || REDACTED;
|
|
58
|
+
const sensitiveKeyPattern = options.sensitiveKeyPattern || SENSITIVE_KEY_PATTERN;
|
|
59
|
+
|
|
60
|
+
if (isSensitiveKey(currentKey, sensitiveKeyPattern)) {
|
|
61
|
+
return redactSensitiveString(value, redactedValue);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (typeof value === 'string') {
|
|
65
|
+
if (currentKey.toLowerCase() === 'url') {
|
|
66
|
+
return redactSensitiveInUrl(value, options);
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
return value.map((item) => sanitizeValue(item, currentKey, options));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (value && typeof value === 'object') {
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
78
|
+
out[key] = sanitizeValue(nestedValue, key, options);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function sanitizeInteractionRecord(record, options = {}) {
|
|
87
|
+
const mergedOptions = {
|
|
88
|
+
enabled: options.enabled !== false,
|
|
89
|
+
redactedValue: options.redactedValue || REDACTED,
|
|
90
|
+
sensitiveKeyPattern: options.sensitiveKeyPattern || SENSITIVE_KEY_PATTERN
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
if (!mergedOptions.enabled) {
|
|
94
|
+
return record;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return sanitizeValue(record, '', mergedOptions);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export { REDACTED };
|
package/src/snapshot.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import Table from 'cli-table3';
|
|
3
3
|
import { getAllInteractions, saveSnapshotData, getAllSnapshots, loadSnapshotData } from './storage.js';
|
|
4
|
+
import { translateText } from './lingo.js';
|
|
4
5
|
|
|
5
6
|
export async function saveSnapshot(tag) {
|
|
6
7
|
const interactions = getAllInteractions();
|
|
@@ -49,14 +50,17 @@ export async function listSnapshots() {
|
|
|
49
50
|
console.log('');
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
export async function compareSnapshots(tag1, tag2) {
|
|
53
|
+
export async function compareSnapshots(tag1, tag2, lang) {
|
|
53
54
|
try {
|
|
54
55
|
const s1 = loadSnapshotData(tag1);
|
|
55
56
|
const s2 = loadSnapshotData(tag2);
|
|
56
57
|
|
|
57
58
|
console.log(chalk.cyan(`\n đ Comparing Snapshots:`), chalk.white(tag1), chalk.gray('vs'), chalk.white(tag2));
|
|
59
|
+
if (lang) {
|
|
60
|
+
console.log(chalk.magenta(` đ Translating output to: ${chalk.white(lang.toUpperCase())} using Lingo.dev...`));
|
|
61
|
+
}
|
|
58
62
|
|
|
59
|
-
const
|
|
63
|
+
const resultMap = new Map();
|
|
60
64
|
let breakingChanges = 0;
|
|
61
65
|
|
|
62
66
|
// Helper to get path from URL string
|
|
@@ -70,65 +74,183 @@ export async function compareSnapshots(tag1, tag2) {
|
|
|
70
74
|
}
|
|
71
75
|
};
|
|
72
76
|
|
|
77
|
+
const compareByMethodAndPath = (a, b) => {
|
|
78
|
+
const pathA = getPath(a.url || '');
|
|
79
|
+
const pathB = getPath(b.url || '');
|
|
80
|
+
const methodA = String(a.method || '').toUpperCase();
|
|
81
|
+
const methodB = String(b.method || '').toUpperCase();
|
|
82
|
+
if (methodA !== methodB) return methodA.localeCompare(methodB);
|
|
83
|
+
return pathA.localeCompare(pathB);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const s1Interactions = [...s1.interactions].sort(compareByMethodAndPath);
|
|
87
|
+
const s2Interactions = [...s2.interactions].sort(compareByMethodAndPath);
|
|
88
|
+
const consumedS1Indexes = new Set();
|
|
89
|
+
|
|
90
|
+
const isBreakingStatusChange = (beforeStatus, afterStatus) => {
|
|
91
|
+
const before2xx = beforeStatus >= 200 && beforeStatus < 300;
|
|
92
|
+
const after3xx = afterStatus >= 300 && afterStatus < 400;
|
|
93
|
+
const after4xx5xx = afterStatus >= 400;
|
|
94
|
+
|
|
95
|
+
if (before2xx && afterStatus === 304) return false;
|
|
96
|
+
if (before2xx && after3xx) return false;
|
|
97
|
+
if (before2xx && after4xx5xx) return true;
|
|
98
|
+
return beforeStatus !== afterStatus;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const addResult = (type, method, url, msg) => {
|
|
102
|
+
const cleanMethod = String(method || '').toUpperCase();
|
|
103
|
+
const key = `${type}|${cleanMethod}|${url}`;
|
|
104
|
+
if (!resultMap.has(key)) {
|
|
105
|
+
resultMap.set(key, { type, method: cleanMethod, url, messages: [], occurrences: 0 });
|
|
106
|
+
}
|
|
107
|
+
const row = resultMap.get(key);
|
|
108
|
+
row.occurrences += 1;
|
|
109
|
+
if (msg && !row.messages.includes(msg)) {
|
|
110
|
+
row.messages.push(msg);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
73
114
|
// Domain-agnostic comparison: match by Path and Method
|
|
74
|
-
|
|
115
|
+
s2Interactions.forEach(int2 => {
|
|
75
116
|
const path2 = getPath(int2.url);
|
|
76
|
-
const
|
|
117
|
+
const candidates = s1Interactions
|
|
118
|
+
.map((item, index) => ({ item, index }))
|
|
119
|
+
.filter(({ item, index }) => !consumedS1Indexes.has(index) && getPath(item.url) === path2 && item.method === int2.method);
|
|
120
|
+
const match = candidates.find(({ item }) => item.id && int2.id && item.id === int2.id) || candidates[0];
|
|
121
|
+
const int1 = match?.item;
|
|
77
122
|
|
|
78
123
|
if (!int1) {
|
|
79
|
-
|
|
80
|
-
type: 'NEW',
|
|
81
|
-
method: int2.method,
|
|
82
|
-
url: path2,
|
|
83
|
-
msg: chalk.blue('New interaction added')
|
|
84
|
-
});
|
|
124
|
+
addResult('NEW', int2.method, path2, 'New interaction added');
|
|
85
125
|
return;
|
|
86
126
|
}
|
|
127
|
+
consumedS1Indexes.add(match.index);
|
|
87
128
|
|
|
88
129
|
const diffs = [];
|
|
89
130
|
|
|
90
131
|
// Compare status
|
|
91
132
|
if (int1.response.status !== int2.response.status) {
|
|
92
133
|
diffs.push(`Status: ${chalk.gray(int1.response.status)} â ${chalk.red(int2.response.status)}`);
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Deep field comparison (very basic for MVP)
|
|
97
|
-
if (typeof int1.response.body === 'object' && typeof int2.response.body === 'object' && int1.response.body !== null && int2.response.body !== null) {
|
|
98
|
-
const keys1 = Object.keys(int1.response.body);
|
|
99
|
-
const keys2 = Object.keys(int2.response.body);
|
|
100
|
-
|
|
101
|
-
const removed = keys1.filter(k => !keys2.includes(k));
|
|
102
|
-
if (removed.length > 0) {
|
|
103
|
-
diffs.push(`Fields removed: ${chalk.red(removed.join(', '))}`);
|
|
134
|
+
if (isBreakingStatusChange(int1.response.status, int2.response.status)) {
|
|
104
135
|
breakingChanges++;
|
|
105
136
|
}
|
|
106
137
|
}
|
|
107
138
|
|
|
139
|
+
// Helper for deep structural comparison
|
|
140
|
+
const deepCompare = (val1, val2, path = '') => {
|
|
141
|
+
const changes = [];
|
|
142
|
+
|
|
143
|
+
// Handle nulls
|
|
144
|
+
if (val1 === null && val2 !== null) return [{ path, msg: `type changed from null to ${typeof val2}`, breaking: false }];
|
|
145
|
+
if (val1 !== null && val2 === null) return [{ path, msg: `type changed from ${typeof val1} to null`, breaking: false }];
|
|
146
|
+
if (val1 === null && val2 === null) return changes;
|
|
147
|
+
|
|
148
|
+
const type1 = Array.isArray(val1) ? 'array' : typeof val1;
|
|
149
|
+
const type2 = Array.isArray(val2) ? 'array' : typeof val2;
|
|
150
|
+
|
|
151
|
+
if (type1 !== type2) {
|
|
152
|
+
changes.push({ path, msg: `type changed from ${chalk.yellow(type1)} to ${chalk.yellow(type2)}`, breaking: true });
|
|
153
|
+
return changes;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (type1 === 'object') {
|
|
157
|
+
const keys1 = Object.keys(val1);
|
|
158
|
+
const keys2 = Object.keys(val2);
|
|
159
|
+
|
|
160
|
+
// Check for removed keys (Breaking)
|
|
161
|
+
for (const k of keys1) {
|
|
162
|
+
const currentPath = path ? `${path}.${k}` : k;
|
|
163
|
+
if (!keys2.includes(k)) {
|
|
164
|
+
changes.push({ path: currentPath, msg: `was ${chalk.red('removed')}`, breaking: true });
|
|
165
|
+
} else {
|
|
166
|
+
changes.push(...deepCompare(val1[k], val2[k], currentPath));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Check for added keys (Non-breaking)
|
|
171
|
+
for (const k of keys2) {
|
|
172
|
+
const currentPath = path ? `${path}.${k}` : k;
|
|
173
|
+
if (!keys1.includes(k)) {
|
|
174
|
+
changes.push({ path: currentPath, msg: `was ${chalk.green('added')}`, breaking: false });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} else if (type1 === 'array') {
|
|
178
|
+
// Array structure validation (check first item schema only if exists)
|
|
179
|
+
if (val1.length > 0 && val2.length > 0) {
|
|
180
|
+
const itemPath = path ? `${path}[0]` : '[0]';
|
|
181
|
+
changes.push(...deepCompare(val1[0], val2[0], itemPath));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return changes;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
if (int1.response.body !== undefined && int2.response.body !== undefined) {
|
|
189
|
+
const structuralChanges = deepCompare(int1.response.body, int2.response.body);
|
|
190
|
+
for (const change of structuralChanges) {
|
|
191
|
+
diffs.push(`${chalk.cyan(change.path || 'root')} ${change.msg}`);
|
|
192
|
+
if (change.breaking) breakingChanges++;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
108
195
|
if (diffs.length > 0) {
|
|
109
|
-
|
|
110
|
-
type: 'CHANGE',
|
|
111
|
-
method: int2.method,
|
|
112
|
-
url: path2,
|
|
113
|
-
msg: diffs.join('\n ')
|
|
114
|
-
});
|
|
196
|
+
addResult('CHANGE', int2.method, path2, diffs.join('\n '));
|
|
115
197
|
}
|
|
116
198
|
});
|
|
117
199
|
|
|
200
|
+
s1Interactions.forEach((int1, index) => {
|
|
201
|
+
if (consumedS1Indexes.has(index)) return;
|
|
202
|
+
const path1 = getPath(int1.url);
|
|
203
|
+
addResult('REMOVED', int1.method, path1, 'Interaction removed in target snapshot');
|
|
204
|
+
breakingChanges++;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const results = [...resultMap.values()];
|
|
208
|
+
|
|
118
209
|
if (results.length === 0) {
|
|
119
|
-
|
|
210
|
+
let finalMsg = 'No differences detected. Your API is stable!';
|
|
211
|
+
if (lang) finalMsg = await translateText(finalMsg, lang);
|
|
212
|
+
console.log(chalk.green(`\n â
${finalMsg}\n`));
|
|
120
213
|
} else {
|
|
121
214
|
console.log('');
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
215
|
+
|
|
216
|
+
const sortedResults = [...results].sort((a, b) => {
|
|
217
|
+
const methodA = String(a.method || '').toUpperCase();
|
|
218
|
+
const methodB = String(b.method || '').toUpperCase();
|
|
219
|
+
if (methodA !== methodB) return methodA.localeCompare(methodB);
|
|
220
|
+
return String(a.url || '').localeCompare(String(b.url || ''));
|
|
126
221
|
});
|
|
222
|
+
|
|
223
|
+
for (const res of sortedResults) {
|
|
224
|
+
let printMsg = res.messages.join('\n ');
|
|
225
|
+
if (res.occurrences > 1 && (res.type === 'NEW' || res.type === 'REMOVED')) {
|
|
226
|
+
printMsg += `\n (${res.occurrences} occurrences)`;
|
|
227
|
+
}
|
|
228
|
+
if (lang) {
|
|
229
|
+
// Basic translation hook for individual diff items (stripping ansi)
|
|
230
|
+
const cleanMsg = printMsg.replace(/\x1B\[[0-9;]*m/g, '');
|
|
231
|
+
const translatedMsg = await translateText(cleanMsg, lang);
|
|
232
|
+
printMsg = chalk.yellow('[Translated] ') + translatedMsg;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const symbol = res.type === 'NEW'
|
|
236
|
+
? chalk.blue('+')
|
|
237
|
+
: res.type === 'REMOVED'
|
|
238
|
+
? chalk.red('-')
|
|
239
|
+
: chalk.yellow('â ī¸');
|
|
240
|
+
console.log(` ${symbol} ${chalk.white(res.method)} ${chalk.gray(res.url)}`);
|
|
241
|
+
console.log(` ${printMsg}`);
|
|
242
|
+
}
|
|
127
243
|
|
|
244
|
+
let alertMsg = breakingChanges > 0
|
|
245
|
+
? `Detected ${breakingChanges} potential breaking changes!`
|
|
246
|
+
: `Non-breaking changes detected.`;
|
|
247
|
+
|
|
248
|
+
if (lang) alertMsg = await translateText(alertMsg, lang);
|
|
249
|
+
|
|
128
250
|
if (breakingChanges > 0) {
|
|
129
|
-
console.log(chalk.red(`\n đ¨
|
|
251
|
+
console.log(chalk.red(`\n đ¨ ${alertMsg}\n`));
|
|
130
252
|
} else {
|
|
131
|
-
console.log(chalk.blue(
|
|
253
|
+
console.log(chalk.blue(`\n âšī¸ ${alertMsg}\n`));
|
|
132
254
|
}
|
|
133
255
|
}
|
|
134
256
|
|