kiroo 0.8.0 → 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 -293
- package/bin/kiroo.js +361 -288
- package/package.json +2 -1
- package/src/analyze.js +550 -0
- package/src/config.js +109 -0
- package/src/deterministic.js +22 -0
- package/src/env.js +31 -3
- package/src/executor.js +17 -0
- package/src/export.js +503 -93
- package/src/init.js +80 -48
- package/src/lingo.js +32 -36
- package/src/proxy.js +120 -0
- package/src/replay.js +5 -4
- package/src/sanitizer.js +100 -0
- package/src/snapshot.js +76 -19
- 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
CHANGED
|
@@ -1,36 +1,32 @@
|
|
|
1
|
-
import { LingoDotDevEngine } from "lingo.dev/sdk";
|
|
2
|
-
import chalk from "chalk";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
function getLingoEngine() {
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
console.log(chalk.red(`\n ⚠️ Translation failed: ${error.message}`));
|
|
34
|
-
return text;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
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
|
@@ -60,7 +60,7 @@ export async function compareSnapshots(tag1, tag2, lang) {
|
|
|
60
60
|
console.log(chalk.magenta(` 🌍 Translating output to: ${chalk.white(lang.toUpperCase())} using Lingo.dev...`));
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
const
|
|
63
|
+
const resultMap = new Map();
|
|
64
64
|
let breakingChanges = 0;
|
|
65
65
|
|
|
66
66
|
// Helper to get path from URL string
|
|
@@ -74,27 +74,66 @@ export async function compareSnapshots(tag1, tag2, lang) {
|
|
|
74
74
|
}
|
|
75
75
|
};
|
|
76
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
|
+
|
|
77
114
|
// Domain-agnostic comparison: match by Path and Method
|
|
78
|
-
|
|
115
|
+
s2Interactions.forEach(int2 => {
|
|
79
116
|
const path2 = getPath(int2.url);
|
|
80
|
-
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;
|
|
81
122
|
|
|
82
123
|
if (!int1) {
|
|
83
|
-
|
|
84
|
-
type: 'NEW',
|
|
85
|
-
method: int2.method,
|
|
86
|
-
url: path2,
|
|
87
|
-
msg: chalk.blue('New interaction added')
|
|
88
|
-
});
|
|
124
|
+
addResult('NEW', int2.method, path2, 'New interaction added');
|
|
89
125
|
return;
|
|
90
126
|
}
|
|
127
|
+
consumedS1Indexes.add(match.index);
|
|
91
128
|
|
|
92
129
|
const diffs = [];
|
|
93
130
|
|
|
94
131
|
// Compare status
|
|
95
132
|
if (int1.response.status !== int2.response.status) {
|
|
96
133
|
diffs.push(`Status: ${chalk.gray(int1.response.status)} → ${chalk.red(int2.response.status)}`);
|
|
97
|
-
|
|
134
|
+
if (isBreakingStatusChange(int1.response.status, int2.response.status)) {
|
|
135
|
+
breakingChanges++;
|
|
136
|
+
}
|
|
98
137
|
}
|
|
99
138
|
|
|
100
139
|
// Helper for deep structural comparison
|
|
@@ -154,15 +193,19 @@ export async function compareSnapshots(tag1, tag2, lang) {
|
|
|
154
193
|
}
|
|
155
194
|
}
|
|
156
195
|
if (diffs.length > 0) {
|
|
157
|
-
|
|
158
|
-
type: 'CHANGE',
|
|
159
|
-
method: int2.method,
|
|
160
|
-
url: path2,
|
|
161
|
-
msg: diffs.join('\n ')
|
|
162
|
-
});
|
|
196
|
+
addResult('CHANGE', int2.method, path2, diffs.join('\n '));
|
|
163
197
|
}
|
|
164
198
|
});
|
|
165
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
|
+
|
|
166
209
|
if (results.length === 0) {
|
|
167
210
|
let finalMsg = 'No differences detected. Your API is stable!';
|
|
168
211
|
if (lang) finalMsg = await translateText(finalMsg, lang);
|
|
@@ -170,8 +213,18 @@ export async function compareSnapshots(tag1, tag2, lang) {
|
|
|
170
213
|
} else {
|
|
171
214
|
console.log('');
|
|
172
215
|
|
|
173
|
-
|
|
174
|
-
|
|
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 || ''));
|
|
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
|
+
}
|
|
175
228
|
if (lang) {
|
|
176
229
|
// Basic translation hook for individual diff items (stripping ansi)
|
|
177
230
|
const cleanMsg = printMsg.replace(/\x1B\[[0-9;]*m/g, '');
|
|
@@ -179,7 +232,11 @@ export async function compareSnapshots(tag1, tag2, lang) {
|
|
|
179
232
|
printMsg = chalk.yellow('[Translated] ') + translatedMsg;
|
|
180
233
|
}
|
|
181
234
|
|
|
182
|
-
const symbol = res.type === 'NEW'
|
|
235
|
+
const symbol = res.type === 'NEW'
|
|
236
|
+
? chalk.blue('+')
|
|
237
|
+
: res.type === 'REMOVED'
|
|
238
|
+
? chalk.red('-')
|
|
239
|
+
: chalk.yellow('⚠️');
|
|
183
240
|
console.log(` ${symbol} ${chalk.white(res.method)} ${chalk.gray(res.url)}`);
|
|
184
241
|
console.log(` ${printMsg}`);
|
|
185
242
|
}
|