@curl-runner/cli 1.14.0 → 1.16.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/package.json +2 -1
- package/src/ci-exit.test.ts +1 -0
- package/src/cli.ts +308 -14
- package/src/commands/upgrade.ts +262 -0
- package/src/diff/baseline-manager.test.ts +181 -0
- package/src/diff/baseline-manager.ts +266 -0
- package/src/diff/diff-formatter.ts +316 -0
- package/src/diff/index.ts +3 -0
- package/src/diff/response-differ.test.ts +330 -0
- package/src/diff/response-differ.ts +489 -0
- package/src/parser/yaml.ts +2 -3
- package/src/types/bun-yaml.d.ts +11 -0
- package/src/types/config.ts +102 -0
- package/src/utils/curl-builder.ts +9 -1
- package/src/utils/installation-detector.test.ts +52 -0
- package/src/utils/installation-detector.ts +123 -0
- package/src/utils/logger.ts +1 -1
- package/src/utils/version-checker.ts +10 -17
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Upgrade command for curl-runner
|
|
2
|
+
// Automatically detects installation source and upgrades to latest version
|
|
3
|
+
|
|
4
|
+
import { color } from '../utils/colors';
|
|
5
|
+
import {
|
|
6
|
+
type DetectionResult,
|
|
7
|
+
detectInstallationSource,
|
|
8
|
+
getUpgradeCommand,
|
|
9
|
+
getUpgradeCommandWindows,
|
|
10
|
+
type InstallationSource,
|
|
11
|
+
isWindows,
|
|
12
|
+
} from '../utils/installation-detector';
|
|
13
|
+
import { getVersion } from '../version';
|
|
14
|
+
|
|
15
|
+
const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@curl-runner/cli/latest';
|
|
16
|
+
const GITHUB_API_URL = 'https://api.github.com/repos/alexvcasillas/curl-runner/releases/latest';
|
|
17
|
+
|
|
18
|
+
interface UpgradeOptions {
|
|
19
|
+
dryRun?: boolean;
|
|
20
|
+
force?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class UpgradeCommand {
|
|
24
|
+
async run(args: string[]): Promise<void> {
|
|
25
|
+
const options = this.parseArgs(args);
|
|
26
|
+
|
|
27
|
+
console.log();
|
|
28
|
+
console.log(color('curl-runner upgrade', 'bright'));
|
|
29
|
+
console.log();
|
|
30
|
+
|
|
31
|
+
// Detect installation source
|
|
32
|
+
const detection = detectInstallationSource();
|
|
33
|
+
console.log(`${color('Installation:', 'cyan')} ${this.formatSource(detection.source)}`);
|
|
34
|
+
console.log(`${color('Current version:', 'cyan')} ${getVersion()}`);
|
|
35
|
+
|
|
36
|
+
// Fetch latest version
|
|
37
|
+
const latestVersion = await this.fetchLatestVersion(detection.source);
|
|
38
|
+
if (!latestVersion) {
|
|
39
|
+
console.log(color('Failed to fetch latest version', 'red'));
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
console.log(`${color('Latest version:', 'cyan')} ${latestVersion}`);
|
|
43
|
+
console.log();
|
|
44
|
+
|
|
45
|
+
// Compare versions
|
|
46
|
+
const currentVersion = getVersion();
|
|
47
|
+
if (!options.force && !this.isNewerVersion(currentVersion, latestVersion)) {
|
|
48
|
+
console.log(color('Already up to date!', 'green'));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Get upgrade command
|
|
53
|
+
const upgradeCmd = isWindows()
|
|
54
|
+
? getUpgradeCommandWindows(detection.source)
|
|
55
|
+
: getUpgradeCommand(detection.source);
|
|
56
|
+
|
|
57
|
+
if (options.dryRun) {
|
|
58
|
+
console.log(color('Dry run - would execute:', 'yellow'));
|
|
59
|
+
console.log(` ${color(upgradeCmd, 'cyan')}`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Execute upgrade
|
|
64
|
+
console.log(`${color('Upgrading...', 'yellow')}`);
|
|
65
|
+
console.log();
|
|
66
|
+
|
|
67
|
+
await this.executeUpgrade(detection, upgradeCmd);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private parseArgs(args: string[]): UpgradeOptions {
|
|
71
|
+
const options: UpgradeOptions = {};
|
|
72
|
+
|
|
73
|
+
for (const arg of args) {
|
|
74
|
+
if (arg === '--dry-run' || arg === '-n') {
|
|
75
|
+
options.dryRun = true;
|
|
76
|
+
} else if (arg === '--force' || arg === '-f') {
|
|
77
|
+
options.force = true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return options;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private formatSource(source: InstallationSource): string {
|
|
85
|
+
switch (source) {
|
|
86
|
+
case 'bun':
|
|
87
|
+
return 'bun (global)';
|
|
88
|
+
case 'npm':
|
|
89
|
+
return 'npm (global)';
|
|
90
|
+
case 'curl':
|
|
91
|
+
return 'curl installer';
|
|
92
|
+
case 'standalone':
|
|
93
|
+
return 'standalone binary';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async fetchLatestVersion(source: InstallationSource): Promise<string | null> {
|
|
98
|
+
try {
|
|
99
|
+
// For npm/bun, use npm registry
|
|
100
|
+
if (source === 'bun' || source === 'npm') {
|
|
101
|
+
const response = await fetch(NPM_REGISTRY_URL, {
|
|
102
|
+
signal: AbortSignal.timeout(5000),
|
|
103
|
+
});
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const data = (await response.json()) as { version: string };
|
|
108
|
+
return data.version;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// For curl/standalone, use GitHub releases
|
|
112
|
+
const response = await fetch(GITHUB_API_URL, {
|
|
113
|
+
signal: AbortSignal.timeout(5000),
|
|
114
|
+
headers: {
|
|
115
|
+
Accept: 'application/vnd.github.v3+json',
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const data = (await response.json()) as { tag_name: string };
|
|
122
|
+
return data.tag_name.replace(/^v/, '');
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private isNewerVersion(current: string, latest: string): boolean {
|
|
129
|
+
const currentVersion = current.replace(/^v/, '');
|
|
130
|
+
const latestVersion = latest.replace(/^v/, '');
|
|
131
|
+
|
|
132
|
+
const currentParts = currentVersion.split('.').map(Number);
|
|
133
|
+
const latestParts = latestVersion.split('.').map(Number);
|
|
134
|
+
|
|
135
|
+
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
|
|
136
|
+
const currentPart = currentParts[i] || 0;
|
|
137
|
+
const latestPart = latestParts[i] || 0;
|
|
138
|
+
|
|
139
|
+
if (latestPart > currentPart) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
if (latestPart < currentPart) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async executeUpgrade(detection: DetectionResult, upgradeCmd: string): Promise<void> {
|
|
151
|
+
const { source } = detection;
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
if (source === 'bun') {
|
|
155
|
+
await this.runBunUpgrade();
|
|
156
|
+
} else if (source === 'npm') {
|
|
157
|
+
await this.runNpmUpgrade();
|
|
158
|
+
} else {
|
|
159
|
+
// curl or standalone - run shell command
|
|
160
|
+
await this.runShellUpgrade(upgradeCmd);
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
164
|
+
|
|
165
|
+
// Check for permission errors
|
|
166
|
+
if (errorMsg.includes('EACCES') || errorMsg.includes('permission')) {
|
|
167
|
+
console.log();
|
|
168
|
+
console.log(color('Permission denied. Try running with sudo:', 'yellow'));
|
|
169
|
+
console.log(` ${color(`sudo ${upgradeCmd}`, 'cyan')}`);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
console.log(color(`Upgrade failed: ${errorMsg}`, 'red'));
|
|
174
|
+
console.log();
|
|
175
|
+
console.log(color('Manual upgrade:', 'yellow'));
|
|
176
|
+
console.log(` ${color(upgradeCmd, 'cyan')}`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async runBunUpgrade(): Promise<void> {
|
|
182
|
+
const proc = Bun.spawn(['bun', 'install', '-g', '@curl-runner/cli@latest'], {
|
|
183
|
+
stdout: 'inherit',
|
|
184
|
+
stderr: 'inherit',
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const exitCode = await proc.exited;
|
|
188
|
+
if (exitCode !== 0) {
|
|
189
|
+
throw new Error(`bun install failed with exit code ${exitCode}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
this.showSuccess();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async runNpmUpgrade(): Promise<void> {
|
|
196
|
+
const proc = Bun.spawn(['npm', 'install', '-g', '@curl-runner/cli@latest'], {
|
|
197
|
+
stdout: 'inherit',
|
|
198
|
+
stderr: 'inherit',
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const exitCode = await proc.exited;
|
|
202
|
+
if (exitCode !== 0) {
|
|
203
|
+
throw new Error(`npm install failed with exit code ${exitCode}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
this.showSuccess();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async runShellUpgrade(cmd: string): Promise<void> {
|
|
210
|
+
let proc: ReturnType<typeof Bun.spawn>;
|
|
211
|
+
|
|
212
|
+
if (isWindows()) {
|
|
213
|
+
// PowerShell for Windows
|
|
214
|
+
proc = Bun.spawn(['powershell', '-Command', cmd], {
|
|
215
|
+
stdout: 'inherit',
|
|
216
|
+
stderr: 'inherit',
|
|
217
|
+
});
|
|
218
|
+
} else {
|
|
219
|
+
// Bash for Unix
|
|
220
|
+
proc = Bun.spawn(['bash', '-c', cmd], {
|
|
221
|
+
stdout: 'inherit',
|
|
222
|
+
stderr: 'inherit',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const exitCode = await proc.exited;
|
|
227
|
+
if (exitCode !== 0) {
|
|
228
|
+
throw new Error(`Upgrade script failed with exit code ${exitCode}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
this.showSuccess();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private showSuccess(): void {
|
|
235
|
+
console.log();
|
|
236
|
+
console.log(color('Upgrade complete!', 'green'));
|
|
237
|
+
console.log();
|
|
238
|
+
console.log('Run `curl-runner --version` to verify.');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function showUpgradeHelp(): void {
|
|
243
|
+
console.log(`
|
|
244
|
+
${color('curl-runner upgrade', 'bright')}
|
|
245
|
+
|
|
246
|
+
Automatically upgrade curl-runner to the latest version.
|
|
247
|
+
Detects installation source (bun, npm, curl) and uses appropriate method.
|
|
248
|
+
|
|
249
|
+
${color('USAGE:', 'yellow')}
|
|
250
|
+
curl-runner upgrade [options]
|
|
251
|
+
|
|
252
|
+
${color('OPTIONS:', 'yellow')}
|
|
253
|
+
-n, --dry-run Show what would be executed without running
|
|
254
|
+
-f, --force Force upgrade even if already on latest version
|
|
255
|
+
-h, --help Show this help message
|
|
256
|
+
|
|
257
|
+
${color('EXAMPLES:', 'yellow')}
|
|
258
|
+
curl-runner upgrade # Upgrade to latest
|
|
259
|
+
curl-runner upgrade --dry-run # Preview upgrade command
|
|
260
|
+
curl-runner upgrade --force # Force reinstall latest
|
|
261
|
+
`);
|
|
262
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ExecutionResult } from '../types/config';
|
|
3
|
+
import { BaselineManager } from './baseline-manager';
|
|
4
|
+
|
|
5
|
+
describe('BaselineManager', () => {
|
|
6
|
+
describe('getBaselinePath', () => {
|
|
7
|
+
test('should generate correct baseline path', () => {
|
|
8
|
+
const manager = new BaselineManager({});
|
|
9
|
+
expect(manager.getBaselinePath('tests/api.yaml', 'staging')).toBe(
|
|
10
|
+
'tests/__baselines__/api.staging.baseline.json',
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('should use custom directory', () => {
|
|
15
|
+
const manager = new BaselineManager({ dir: '.baselines' });
|
|
16
|
+
expect(manager.getBaselinePath('api.yaml', 'prod')).toBe('.baselines/api.prod.baseline.json');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('should handle nested paths', () => {
|
|
20
|
+
const manager = new BaselineManager({});
|
|
21
|
+
expect(manager.getBaselinePath('tests/integration/users.yaml', 'staging')).toBe(
|
|
22
|
+
'tests/integration/__baselines__/users.staging.baseline.json',
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('should handle labels with special characters', () => {
|
|
27
|
+
const manager = new BaselineManager({});
|
|
28
|
+
expect(manager.getBaselinePath('api.yaml', 'v1.0.0')).toBe(
|
|
29
|
+
'__baselines__/api.v1.0.0.baseline.json',
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('getBaselineDir', () => {
|
|
35
|
+
test('should return correct directory', () => {
|
|
36
|
+
const manager = new BaselineManager({});
|
|
37
|
+
expect(manager.getBaselineDir('tests/api.yaml')).toBe('tests/__baselines__');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('should use custom directory', () => {
|
|
41
|
+
const manager = new BaselineManager({ dir: 'snapshots' });
|
|
42
|
+
expect(manager.getBaselineDir('api.yaml')).toBe('snapshots');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('createBaseline', () => {
|
|
47
|
+
const mockResult: ExecutionResult = {
|
|
48
|
+
request: { url: 'https://api.example.com', name: 'Get Users' },
|
|
49
|
+
success: true,
|
|
50
|
+
status: 200,
|
|
51
|
+
headers: {
|
|
52
|
+
'Content-Type': 'application/json',
|
|
53
|
+
'X-Request-Id': 'abc123',
|
|
54
|
+
},
|
|
55
|
+
body: {
|
|
56
|
+
id: 1,
|
|
57
|
+
name: 'Test',
|
|
58
|
+
timestamp: '2024-01-01',
|
|
59
|
+
},
|
|
60
|
+
metrics: {
|
|
61
|
+
duration: 150,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
test('should create baseline with all fields', () => {
|
|
66
|
+
const manager = new BaselineManager({});
|
|
67
|
+
const baseline = manager.createBaseline(mockResult, {});
|
|
68
|
+
|
|
69
|
+
expect(baseline.status).toBe(200);
|
|
70
|
+
expect(baseline.body).toEqual(mockResult.body);
|
|
71
|
+
expect(baseline.headers).toBeDefined();
|
|
72
|
+
expect(baseline.hash).toBeDefined();
|
|
73
|
+
expect(baseline.capturedAt).toBeDefined();
|
|
74
|
+
expect(baseline.timing).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('should include timing when configured', () => {
|
|
78
|
+
const manager = new BaselineManager({});
|
|
79
|
+
const baseline = manager.createBaseline(mockResult, { includeTimings: true });
|
|
80
|
+
|
|
81
|
+
expect(baseline.timing).toBe(150);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('should normalize headers (lowercase, sorted)', () => {
|
|
85
|
+
const manager = new BaselineManager({});
|
|
86
|
+
const baseline = manager.createBaseline(mockResult, {});
|
|
87
|
+
|
|
88
|
+
expect(baseline.headers).toEqual({
|
|
89
|
+
'content-type': 'application/json',
|
|
90
|
+
'x-request-id': 'abc123',
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('hash', () => {
|
|
96
|
+
test('should generate consistent hashes', () => {
|
|
97
|
+
const manager = new BaselineManager({});
|
|
98
|
+
const content = { id: 1, name: 'test' };
|
|
99
|
+
|
|
100
|
+
const hash1 = manager.hash(content);
|
|
101
|
+
const hash2 = manager.hash(content);
|
|
102
|
+
|
|
103
|
+
expect(hash1).toBe(hash2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('should generate different hashes for different content', () => {
|
|
107
|
+
const manager = new BaselineManager({});
|
|
108
|
+
|
|
109
|
+
const hash1 = manager.hash({ id: 1 });
|
|
110
|
+
const hash2 = manager.hash({ id: 2 });
|
|
111
|
+
|
|
112
|
+
expect(hash1).not.toBe(hash2);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('should produce 8 character hash', () => {
|
|
116
|
+
const manager = new BaselineManager({});
|
|
117
|
+
const hash = manager.hash({ data: 'test' });
|
|
118
|
+
|
|
119
|
+
expect(hash).toHaveLength(8);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('mergeConfig', () => {
|
|
124
|
+
test('should return null if not enabled', () => {
|
|
125
|
+
const config = BaselineManager.mergeConfig({}, undefined);
|
|
126
|
+
expect(config).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('should return null if explicitly disabled', () => {
|
|
130
|
+
const config = BaselineManager.mergeConfig({}, { enabled: false });
|
|
131
|
+
expect(config).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('should handle boolean true', () => {
|
|
135
|
+
const config = BaselineManager.mergeConfig({}, true);
|
|
136
|
+
expect(config).toEqual({
|
|
137
|
+
enabled: true,
|
|
138
|
+
exclude: [],
|
|
139
|
+
match: {},
|
|
140
|
+
includeTimings: false,
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('should merge global and request excludes', () => {
|
|
145
|
+
const config = BaselineManager.mergeConfig(
|
|
146
|
+
{ exclude: ['*.timestamp'] },
|
|
147
|
+
{ enabled: true, exclude: ['body.id'] },
|
|
148
|
+
);
|
|
149
|
+
expect(config?.exclude).toEqual(['*.timestamp', 'body.id']);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('should merge match rules', () => {
|
|
153
|
+
const config = BaselineManager.mergeConfig(
|
|
154
|
+
{ match: { 'body.id': '*' } },
|
|
155
|
+
{ enabled: true, match: { 'body.token': 'regex:^[a-z]+$' } },
|
|
156
|
+
);
|
|
157
|
+
expect(config?.match).toEqual({
|
|
158
|
+
'body.id': '*',
|
|
159
|
+
'body.token': 'regex:^[a-z]+$',
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('should use global enabled', () => {
|
|
164
|
+
const config = BaselineManager.mergeConfig({ enabled: true }, undefined);
|
|
165
|
+
expect(config?.enabled).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('should inherit includeTimings from global', () => {
|
|
169
|
+
const config = BaselineManager.mergeConfig({ enabled: true, includeTimings: true }, true);
|
|
170
|
+
expect(config?.includeTimings).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('should allow request to override includeTimings', () => {
|
|
174
|
+
const config = BaselineManager.mergeConfig(
|
|
175
|
+
{ enabled: true, includeTimings: false },
|
|
176
|
+
{ enabled: true, includeTimings: true },
|
|
177
|
+
);
|
|
178
|
+
expect(config?.includeTimings).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import type {
|
|
4
|
+
Baseline,
|
|
5
|
+
BaselineFile,
|
|
6
|
+
DiffConfig,
|
|
7
|
+
ExecutionResult,
|
|
8
|
+
GlobalDiffConfig,
|
|
9
|
+
} from '../types/config';
|
|
10
|
+
|
|
11
|
+
const BASELINE_VERSION = 1;
|
|
12
|
+
const DEFAULT_BASELINE_DIR = '__baselines__';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Manages baseline files: reading, writing, and listing.
|
|
16
|
+
*/
|
|
17
|
+
export class BaselineManager {
|
|
18
|
+
private baselineDir: string;
|
|
19
|
+
private writeLocks: Map<string, Promise<void>> = new Map();
|
|
20
|
+
|
|
21
|
+
constructor(globalConfig: GlobalDiffConfig = {}) {
|
|
22
|
+
this.baselineDir = globalConfig.dir || DEFAULT_BASELINE_DIR;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Gets the baseline file path for a label.
|
|
27
|
+
*/
|
|
28
|
+
getBaselinePath(yamlPath: string, label: string): string {
|
|
29
|
+
const dir = path.dirname(yamlPath);
|
|
30
|
+
const basename = path.basename(yamlPath, path.extname(yamlPath));
|
|
31
|
+
return path.join(dir, this.baselineDir, `${basename}.${label}.baseline.json`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Gets the baseline directory for a YAML file.
|
|
36
|
+
*/
|
|
37
|
+
getBaselineDir(yamlPath: string): string {
|
|
38
|
+
return path.join(path.dirname(yamlPath), this.baselineDir);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Loads baseline file for a specific label.
|
|
43
|
+
*/
|
|
44
|
+
async load(yamlPath: string, label: string): Promise<BaselineFile | null> {
|
|
45
|
+
const baselinePath = this.getBaselinePath(yamlPath, label);
|
|
46
|
+
try {
|
|
47
|
+
const file = Bun.file(baselinePath);
|
|
48
|
+
if (!(await file.exists())) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const content = await file.text();
|
|
52
|
+
return JSON.parse(content) as BaselineFile;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Saves baseline file with write queue for parallel safety.
|
|
60
|
+
*/
|
|
61
|
+
async save(yamlPath: string, label: string, data: BaselineFile): Promise<void> {
|
|
62
|
+
const baselinePath = this.getBaselinePath(yamlPath, label);
|
|
63
|
+
|
|
64
|
+
const existingLock = this.writeLocks.get(baselinePath);
|
|
65
|
+
const writePromise = (async () => {
|
|
66
|
+
if (existingLock) {
|
|
67
|
+
await existingLock;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const dir = path.dirname(baselinePath);
|
|
71
|
+
await fs.mkdir(dir, { recursive: true });
|
|
72
|
+
|
|
73
|
+
const content = JSON.stringify(data, null, 2);
|
|
74
|
+
await Bun.write(baselinePath, content);
|
|
75
|
+
})();
|
|
76
|
+
|
|
77
|
+
this.writeLocks.set(baselinePath, writePromise);
|
|
78
|
+
await writePromise;
|
|
79
|
+
this.writeLocks.delete(baselinePath);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Gets a single baseline by request name.
|
|
84
|
+
*/
|
|
85
|
+
async get(yamlPath: string, label: string, requestName: string): Promise<Baseline | null> {
|
|
86
|
+
const file = await this.load(yamlPath, label);
|
|
87
|
+
return file?.baselines[requestName] || null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Lists all available baseline labels for a YAML file.
|
|
92
|
+
*/
|
|
93
|
+
async listLabels(yamlPath: string): Promise<string[]> {
|
|
94
|
+
const dir = this.getBaselineDir(yamlPath);
|
|
95
|
+
const basename = path.basename(yamlPath, path.extname(yamlPath));
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const files = await fs.readdir(dir);
|
|
99
|
+
const labels: string[] = [];
|
|
100
|
+
|
|
101
|
+
for (const file of files) {
|
|
102
|
+
const match = file.match(new RegExp(`^${basename}\\.(.+)\\.baseline\\.json$`));
|
|
103
|
+
if (match) {
|
|
104
|
+
labels.push(match[1]);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return labels.sort();
|
|
109
|
+
} catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Creates a baseline from execution result.
|
|
116
|
+
*/
|
|
117
|
+
createBaseline(result: ExecutionResult, config: DiffConfig): Baseline {
|
|
118
|
+
const baseline: Baseline = {
|
|
119
|
+
hash: '',
|
|
120
|
+
capturedAt: new Date().toISOString(),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (result.status !== undefined) {
|
|
124
|
+
baseline.status = result.status;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (result.headers) {
|
|
128
|
+
baseline.headers = this.normalizeHeaders(result.headers);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (result.body !== undefined) {
|
|
132
|
+
baseline.body = result.body;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (config.includeTimings && result.metrics?.duration !== undefined) {
|
|
136
|
+
baseline.timing = result.metrics.duration;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
baseline.hash = this.hash(baseline);
|
|
140
|
+
|
|
141
|
+
return baseline;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Normalizes headers for consistent comparison.
|
|
146
|
+
*/
|
|
147
|
+
private normalizeHeaders(headers: Record<string, string>): Record<string, string> {
|
|
148
|
+
const normalized: Record<string, string> = {};
|
|
149
|
+
const sortedKeys = Object.keys(headers).sort();
|
|
150
|
+
for (const key of sortedKeys) {
|
|
151
|
+
normalized[key.toLowerCase()] = headers[key];
|
|
152
|
+
}
|
|
153
|
+
return normalized;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Generates a hash for baseline content.
|
|
158
|
+
*/
|
|
159
|
+
hash(content: unknown): string {
|
|
160
|
+
const str = JSON.stringify(content);
|
|
161
|
+
const hasher = new Bun.CryptoHasher('md5');
|
|
162
|
+
hasher.update(str);
|
|
163
|
+
return hasher.digest('hex').slice(0, 8);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Saves execution results as a baseline.
|
|
168
|
+
*/
|
|
169
|
+
async saveBaseline(
|
|
170
|
+
yamlPath: string,
|
|
171
|
+
label: string,
|
|
172
|
+
results: ExecutionResult[],
|
|
173
|
+
config: DiffConfig,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
const baselines: Record<string, Baseline> = {};
|
|
176
|
+
|
|
177
|
+
for (const result of results) {
|
|
178
|
+
if (result.skipped || !result.success) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const name = result.request.name || result.request.url;
|
|
182
|
+
baselines[name] = this.createBaseline(result, config);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const file: BaselineFile = {
|
|
186
|
+
version: BASELINE_VERSION,
|
|
187
|
+
label,
|
|
188
|
+
capturedAt: new Date().toISOString(),
|
|
189
|
+
baselines,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
await this.save(yamlPath, label, file);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Deletes baselines older than specified days.
|
|
197
|
+
*/
|
|
198
|
+
async cleanOldBaselines(yamlPath: string, olderThanDays: number): Promise<number> {
|
|
199
|
+
const dir = this.getBaselineDir(yamlPath);
|
|
200
|
+
const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
|
|
201
|
+
let deleted = 0;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const files = await fs.readdir(dir);
|
|
205
|
+
|
|
206
|
+
for (const file of files) {
|
|
207
|
+
if (!file.endsWith('.baseline.json')) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const filePath = path.join(dir, file);
|
|
212
|
+
const stat = await fs.stat(filePath);
|
|
213
|
+
|
|
214
|
+
if (stat.mtimeMs < cutoff) {
|
|
215
|
+
await fs.unlink(filePath);
|
|
216
|
+
deleted++;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
// Directory doesn't exist or other error
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return deleted;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Merges request-level config with global config.
|
|
228
|
+
*/
|
|
229
|
+
static mergeConfig(
|
|
230
|
+
globalConfig: GlobalDiffConfig | undefined,
|
|
231
|
+
requestConfig: DiffConfig | boolean | undefined,
|
|
232
|
+
): DiffConfig | null {
|
|
233
|
+
if (!requestConfig && !globalConfig?.enabled) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (requestConfig === true) {
|
|
238
|
+
return {
|
|
239
|
+
enabled: true,
|
|
240
|
+
exclude: globalConfig?.exclude || [],
|
|
241
|
+
match: globalConfig?.match || {},
|
|
242
|
+
includeTimings: globalConfig?.includeTimings || false,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (typeof requestConfig === 'object' && requestConfig.enabled !== false) {
|
|
247
|
+
return {
|
|
248
|
+
enabled: true,
|
|
249
|
+
exclude: [...(globalConfig?.exclude || []), ...(requestConfig.exclude || [])],
|
|
250
|
+
match: { ...(globalConfig?.match || {}), ...(requestConfig.match || {}) },
|
|
251
|
+
includeTimings: requestConfig.includeTimings ?? globalConfig?.includeTimings ?? false,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (globalConfig?.enabled && requestConfig === undefined) {
|
|
256
|
+
return {
|
|
257
|
+
enabled: true,
|
|
258
|
+
exclude: globalConfig.exclude || [],
|
|
259
|
+
match: globalConfig.match || {},
|
|
260
|
+
includeTimings: globalConfig.includeTimings || false,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|