@curl-runner/cli 1.9.0 → 1.11.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 +1 -1
- package/src/cli.ts +234 -41
- package/src/executor/request-executor.ts +31 -1
- package/src/snapshot/index.ts +3 -0
- package/src/snapshot/snapshot-differ.test.ts +358 -0
- package/src/snapshot/snapshot-differ.ts +296 -0
- package/src/snapshot/snapshot-formatter.ts +170 -0
- package/src/snapshot/snapshot-manager.test.ts +204 -0
- package/src/snapshot/snapshot-manager.ts +342 -0
- package/src/types/config.ts +98 -0
- package/src/utils/logger.ts +49 -0
- package/src/watcher/file-watcher.test.ts +186 -0
- package/src/watcher/file-watcher.ts +140 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { SnapshotCompareResult, SnapshotDiff } from '../types/config';
|
|
2
|
+
|
|
3
|
+
const COLORS = {
|
|
4
|
+
reset: '\x1b[0m',
|
|
5
|
+
red: '\x1b[31m',
|
|
6
|
+
green: '\x1b[32m',
|
|
7
|
+
yellow: '\x1b[33m',
|
|
8
|
+
cyan: '\x1b[36m',
|
|
9
|
+
dim: '\x1b[2m',
|
|
10
|
+
bright: '\x1b[1m',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Formats snapshot comparison results for terminal output.
|
|
15
|
+
*/
|
|
16
|
+
export class SnapshotFormatter {
|
|
17
|
+
private color(text: string, color: keyof typeof COLORS): string {
|
|
18
|
+
return `${COLORS[color]}${text}${COLORS.reset}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Formats the snapshot result for display.
|
|
23
|
+
*/
|
|
24
|
+
formatResult(requestName: string, result: SnapshotCompareResult): string {
|
|
25
|
+
const lines: string[] = [];
|
|
26
|
+
|
|
27
|
+
if (result.isNew && result.updated) {
|
|
28
|
+
lines.push(
|
|
29
|
+
` ${this.color('NEW', 'cyan')} Snapshot created for "${this.color(requestName, 'bright')}"`,
|
|
30
|
+
);
|
|
31
|
+
return lines.join('\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (result.updated) {
|
|
35
|
+
lines.push(
|
|
36
|
+
` ${this.color('UPDATED', 'yellow')} Snapshot updated for "${this.color(requestName, 'bright')}"`,
|
|
37
|
+
);
|
|
38
|
+
return lines.join('\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (result.match) {
|
|
42
|
+
lines.push(
|
|
43
|
+
` ${this.color('PASS', 'green')} Snapshot matches for "${this.color(requestName, 'bright')}"`,
|
|
44
|
+
);
|
|
45
|
+
return lines.join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Mismatch
|
|
49
|
+
lines.push(
|
|
50
|
+
` ${this.color('FAIL', 'red')} Snapshot mismatch for "${this.color(requestName, 'bright')}"`,
|
|
51
|
+
);
|
|
52
|
+
lines.push('');
|
|
53
|
+
lines.push(this.formatDiff(result.differences));
|
|
54
|
+
lines.push('');
|
|
55
|
+
lines.push(this.color(' Run with --update-snapshots (-u) to update', 'dim'));
|
|
56
|
+
|
|
57
|
+
return lines.join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Formats differences for display.
|
|
62
|
+
*/
|
|
63
|
+
formatDiff(differences: SnapshotDiff[]): string {
|
|
64
|
+
const lines: string[] = [];
|
|
65
|
+
lines.push(` ${this.color('- Expected', 'red')}`);
|
|
66
|
+
lines.push(` ${this.color('+ Received', 'green')}`);
|
|
67
|
+
lines.push('');
|
|
68
|
+
|
|
69
|
+
for (const diff of differences) {
|
|
70
|
+
lines.push(this.formatDifference(diff));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Formats a single difference.
|
|
78
|
+
*/
|
|
79
|
+
private formatDifference(diff: SnapshotDiff): string {
|
|
80
|
+
const lines: string[] = [];
|
|
81
|
+
const path = diff.path || '(root)';
|
|
82
|
+
|
|
83
|
+
switch (diff.type) {
|
|
84
|
+
case 'added':
|
|
85
|
+
lines.push(` ${this.color(path, 'cyan')}:`);
|
|
86
|
+
lines.push(` ${this.color(`+ ${this.stringify(diff.received)}`, 'green')}`);
|
|
87
|
+
break;
|
|
88
|
+
|
|
89
|
+
case 'removed':
|
|
90
|
+
lines.push(` ${this.color(path, 'cyan')}:`);
|
|
91
|
+
lines.push(` ${this.color(`- ${this.stringify(diff.expected)}`, 'red')}`);
|
|
92
|
+
break;
|
|
93
|
+
|
|
94
|
+
case 'changed':
|
|
95
|
+
lines.push(` ${this.color(path, 'cyan')}:`);
|
|
96
|
+
lines.push(` ${this.color(`- ${this.stringify(diff.expected)}`, 'red')}`);
|
|
97
|
+
lines.push(` ${this.color(`+ ${this.stringify(diff.received)}`, 'green')}`);
|
|
98
|
+
break;
|
|
99
|
+
|
|
100
|
+
case 'type_mismatch':
|
|
101
|
+
lines.push(` ${this.color(path, 'cyan')} (type mismatch):`);
|
|
102
|
+
lines.push(
|
|
103
|
+
` ${this.color(`- ${this.stringify(diff.expected)} (${typeof diff.expected})`, 'red')}`,
|
|
104
|
+
);
|
|
105
|
+
lines.push(
|
|
106
|
+
` ${this.color(`+ ${this.stringify(diff.received)} (${typeof diff.received})`, 'green')}`,
|
|
107
|
+
);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return lines.join('\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Converts value to display string.
|
|
116
|
+
*/
|
|
117
|
+
private stringify(value: unknown): string {
|
|
118
|
+
if (value === undefined) {
|
|
119
|
+
return 'undefined';
|
|
120
|
+
}
|
|
121
|
+
if (value === null) {
|
|
122
|
+
return 'null';
|
|
123
|
+
}
|
|
124
|
+
if (typeof value === 'string') {
|
|
125
|
+
return `"${value}"`;
|
|
126
|
+
}
|
|
127
|
+
if (typeof value === 'object') {
|
|
128
|
+
const str = JSON.stringify(value);
|
|
129
|
+
// Truncate long values
|
|
130
|
+
if (str.length > 80) {
|
|
131
|
+
return `${str.slice(0, 77)}...`;
|
|
132
|
+
}
|
|
133
|
+
return str;
|
|
134
|
+
}
|
|
135
|
+
return String(value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Formats summary statistics.
|
|
140
|
+
*/
|
|
141
|
+
formatSummary(stats: SnapshotStats): string {
|
|
142
|
+
const parts: string[] = [];
|
|
143
|
+
|
|
144
|
+
if (stats.passed > 0) {
|
|
145
|
+
parts.push(this.color(`${stats.passed} passed`, 'green'));
|
|
146
|
+
}
|
|
147
|
+
if (stats.failed > 0) {
|
|
148
|
+
parts.push(this.color(`${stats.failed} failed`, 'red'));
|
|
149
|
+
}
|
|
150
|
+
if (stats.updated > 0) {
|
|
151
|
+
parts.push(this.color(`${stats.updated} updated`, 'yellow'));
|
|
152
|
+
}
|
|
153
|
+
if (stats.created > 0) {
|
|
154
|
+
parts.push(this.color(`${stats.created} created`, 'cyan'));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (parts.length === 0) {
|
|
158
|
+
return '';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return `Snapshots: ${parts.join(', ')}`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface SnapshotStats {
|
|
166
|
+
passed: number;
|
|
167
|
+
failed: number;
|
|
168
|
+
updated: number;
|
|
169
|
+
created: number;
|
|
170
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ExecutionResult } from '../types/config';
|
|
3
|
+
import { filterSnapshotBody, SnapshotManager } from './snapshot-manager';
|
|
4
|
+
|
|
5
|
+
describe('SnapshotManager', () => {
|
|
6
|
+
describe('getSnapshotPath', () => {
|
|
7
|
+
test('should generate correct snapshot path', () => {
|
|
8
|
+
const manager = new SnapshotManager({});
|
|
9
|
+
expect(manager.getSnapshotPath('tests/api.yaml')).toBe('tests/__snapshots__/api.snap.json');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('should use custom directory', () => {
|
|
13
|
+
const manager = new SnapshotManager({ dir: '.snapshots' });
|
|
14
|
+
expect(manager.getSnapshotPath('api.yaml')).toBe('.snapshots/api.snap.json');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('should handle nested paths', () => {
|
|
18
|
+
const manager = new SnapshotManager({});
|
|
19
|
+
expect(manager.getSnapshotPath('tests/integration/users.yaml')).toBe(
|
|
20
|
+
'tests/integration/__snapshots__/users.snap.json',
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('createSnapshot', () => {
|
|
26
|
+
const mockResult: ExecutionResult = {
|
|
27
|
+
request: { url: 'https://api.example.com' },
|
|
28
|
+
success: true,
|
|
29
|
+
status: 200,
|
|
30
|
+
headers: {
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
'X-Request-Id': 'abc123',
|
|
33
|
+
},
|
|
34
|
+
body: {
|
|
35
|
+
id: 1,
|
|
36
|
+
name: 'Test',
|
|
37
|
+
timestamp: '2024-01-01',
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
test('should create snapshot with body only by default', () => {
|
|
42
|
+
const manager = new SnapshotManager({});
|
|
43
|
+
const snapshot = manager.createSnapshot(mockResult, { include: ['body'] });
|
|
44
|
+
|
|
45
|
+
expect(snapshot.body).toEqual(mockResult.body);
|
|
46
|
+
expect(snapshot.status).toBeUndefined();
|
|
47
|
+
expect(snapshot.headers).toBeUndefined();
|
|
48
|
+
expect(snapshot.hash).toBeDefined();
|
|
49
|
+
expect(snapshot.updatedAt).toBeDefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('should create snapshot with status', () => {
|
|
53
|
+
const manager = new SnapshotManager({});
|
|
54
|
+
const snapshot = manager.createSnapshot(mockResult, { include: ['status'] });
|
|
55
|
+
|
|
56
|
+
expect(snapshot.status).toBe(200);
|
|
57
|
+
expect(snapshot.body).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('should create snapshot with headers (normalized)', () => {
|
|
61
|
+
const manager = new SnapshotManager({});
|
|
62
|
+
const snapshot = manager.createSnapshot(mockResult, { include: ['headers'] });
|
|
63
|
+
|
|
64
|
+
expect(snapshot.headers).toEqual({
|
|
65
|
+
'content-type': 'application/json',
|
|
66
|
+
'x-request-id': 'abc123',
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('should create snapshot with all components', () => {
|
|
71
|
+
const manager = new SnapshotManager({});
|
|
72
|
+
const snapshot = manager.createSnapshot(mockResult, {
|
|
73
|
+
include: ['status', 'headers', 'body'],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(snapshot.status).toBe(200);
|
|
77
|
+
expect(snapshot.headers).toBeDefined();
|
|
78
|
+
expect(snapshot.body).toBeDefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('hash', () => {
|
|
83
|
+
test('should generate consistent hashes', () => {
|
|
84
|
+
const manager = new SnapshotManager({});
|
|
85
|
+
const content = { id: 1, name: 'test' };
|
|
86
|
+
|
|
87
|
+
const hash1 = manager.hash(content);
|
|
88
|
+
const hash2 = manager.hash(content);
|
|
89
|
+
|
|
90
|
+
expect(hash1).toBe(hash2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('should generate different hashes for different content', () => {
|
|
94
|
+
const manager = new SnapshotManager({});
|
|
95
|
+
|
|
96
|
+
const hash1 = manager.hash({ id: 1 });
|
|
97
|
+
const hash2 = manager.hash({ id: 2 });
|
|
98
|
+
|
|
99
|
+
expect(hash1).not.toBe(hash2);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('mergeConfig', () => {
|
|
104
|
+
test('should return null if not enabled', () => {
|
|
105
|
+
const config = SnapshotManager.mergeConfig({}, undefined);
|
|
106
|
+
expect(config).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('should handle boolean true', () => {
|
|
110
|
+
const config = SnapshotManager.mergeConfig({}, true);
|
|
111
|
+
expect(config).toEqual({
|
|
112
|
+
enabled: true,
|
|
113
|
+
include: ['body'],
|
|
114
|
+
exclude: [],
|
|
115
|
+
match: {},
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('should merge global and request excludes', () => {
|
|
120
|
+
const config = SnapshotManager.mergeConfig(
|
|
121
|
+
{ exclude: ['*.timestamp'] },
|
|
122
|
+
{ enabled: true, exclude: ['body.id'] },
|
|
123
|
+
);
|
|
124
|
+
expect(config?.exclude).toEqual(['*.timestamp', 'body.id']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('should override include from request config', () => {
|
|
128
|
+
const config = SnapshotManager.mergeConfig(
|
|
129
|
+
{ include: ['body'] },
|
|
130
|
+
{ enabled: true, include: ['status', 'body'] },
|
|
131
|
+
);
|
|
132
|
+
expect(config?.include).toEqual(['status', 'body']);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('should use global enabled', () => {
|
|
136
|
+
const config = SnapshotManager.mergeConfig({ enabled: true }, undefined);
|
|
137
|
+
expect(config?.enabled).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('filterSnapshotBody', () => {
|
|
143
|
+
test('should return primitive values unchanged', () => {
|
|
144
|
+
expect(filterSnapshotBody('string', [])).toBe('string');
|
|
145
|
+
expect(filterSnapshotBody(123, [])).toBe(123);
|
|
146
|
+
expect(filterSnapshotBody(null, [])).toBe(null);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('should return body unchanged if no excludes', () => {
|
|
150
|
+
const body = { id: 1, name: 'test' };
|
|
151
|
+
expect(filterSnapshotBody(body, [])).toEqual(body);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('should filter exact paths', () => {
|
|
155
|
+
const body = { id: 1, timestamp: '2024-01-01', name: 'test' };
|
|
156
|
+
const result = filterSnapshotBody(body, ['body.timestamp']);
|
|
157
|
+
expect(result).toEqual({ id: 1, name: 'test' });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('should filter wildcard paths', () => {
|
|
161
|
+
const body = {
|
|
162
|
+
user: { id: 1, createdAt: '2024-01-01' },
|
|
163
|
+
post: { id: 2, createdAt: '2024-01-02' },
|
|
164
|
+
};
|
|
165
|
+
const result = filterSnapshotBody(body, ['body.*.createdAt']);
|
|
166
|
+
expect(result).toEqual({
|
|
167
|
+
user: { id: 1 },
|
|
168
|
+
post: { id: 2 },
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('should filter array wildcards', () => {
|
|
173
|
+
const body = {
|
|
174
|
+
items: [
|
|
175
|
+
{ id: 1, updatedAt: '2024-01-01' },
|
|
176
|
+
{ id: 2, updatedAt: '2024-01-02' },
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
const result = filterSnapshotBody(body, ['body.items[*].updatedAt']);
|
|
180
|
+
expect(result).toEqual({
|
|
181
|
+
items: [{ id: 1 }, { id: 2 }],
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('should handle nested objects', () => {
|
|
186
|
+
const body = {
|
|
187
|
+
data: {
|
|
188
|
+
user: {
|
|
189
|
+
id: 1,
|
|
190
|
+
meta: { lastLogin: '2024-01-01' },
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
const result = filterSnapshotBody(body, ['body.data.user.meta.lastLogin']);
|
|
195
|
+
expect(result).toEqual({
|
|
196
|
+
data: {
|
|
197
|
+
user: {
|
|
198
|
+
id: 1,
|
|
199
|
+
meta: {},
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import type {
|
|
3
|
+
ExecutionResult,
|
|
4
|
+
GlobalSnapshotConfig,
|
|
5
|
+
JsonValue,
|
|
6
|
+
Snapshot,
|
|
7
|
+
SnapshotCompareResult,
|
|
8
|
+
SnapshotConfig,
|
|
9
|
+
SnapshotFile,
|
|
10
|
+
} from '../types/config';
|
|
11
|
+
import { SnapshotDiffer } from './snapshot-differ';
|
|
12
|
+
|
|
13
|
+
const SNAPSHOT_VERSION = 1;
|
|
14
|
+
const DEFAULT_SNAPSHOT_DIR = '__snapshots__';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Manages snapshot files: reading, writing, comparing, and updating.
|
|
18
|
+
*/
|
|
19
|
+
export class SnapshotManager {
|
|
20
|
+
private snapshotDir: string;
|
|
21
|
+
private globalConfig: GlobalSnapshotConfig;
|
|
22
|
+
private writeLocks: Map<string, Promise<void>> = new Map();
|
|
23
|
+
|
|
24
|
+
constructor(globalConfig: GlobalSnapshotConfig = {}) {
|
|
25
|
+
this.globalConfig = globalConfig;
|
|
26
|
+
this.snapshotDir = globalConfig.dir || DEFAULT_SNAPSHOT_DIR;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Gets the snapshot file path for a YAML file.
|
|
31
|
+
*/
|
|
32
|
+
getSnapshotPath(yamlPath: string): string {
|
|
33
|
+
const dir = path.dirname(yamlPath);
|
|
34
|
+
const basename = path.basename(yamlPath, path.extname(yamlPath));
|
|
35
|
+
return path.join(dir, this.snapshotDir, `${basename}.snap.json`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Loads snapshot file for a YAML file.
|
|
40
|
+
*/
|
|
41
|
+
async load(yamlPath: string): Promise<SnapshotFile | null> {
|
|
42
|
+
const snapshotPath = this.getSnapshotPath(yamlPath);
|
|
43
|
+
try {
|
|
44
|
+
const file = Bun.file(snapshotPath);
|
|
45
|
+
if (!(await file.exists())) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const content = await file.text();
|
|
49
|
+
return JSON.parse(content) as SnapshotFile;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Saves snapshot file with write queue for parallel safety.
|
|
57
|
+
*/
|
|
58
|
+
async save(yamlPath: string, data: SnapshotFile): Promise<void> {
|
|
59
|
+
const snapshotPath = this.getSnapshotPath(yamlPath);
|
|
60
|
+
|
|
61
|
+
// Queue writes to prevent race conditions
|
|
62
|
+
const existingLock = this.writeLocks.get(snapshotPath);
|
|
63
|
+
const writePromise = (async () => {
|
|
64
|
+
if (existingLock) {
|
|
65
|
+
await existingLock;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Ensure directory exists
|
|
69
|
+
const dir = path.dirname(snapshotPath);
|
|
70
|
+
const fs = await import('node:fs/promises');
|
|
71
|
+
await fs.mkdir(dir, { recursive: true });
|
|
72
|
+
|
|
73
|
+
// Write with pretty formatting
|
|
74
|
+
const content = JSON.stringify(data, null, 2);
|
|
75
|
+
await Bun.write(snapshotPath, content);
|
|
76
|
+
})();
|
|
77
|
+
|
|
78
|
+
this.writeLocks.set(snapshotPath, writePromise);
|
|
79
|
+
await writePromise;
|
|
80
|
+
this.writeLocks.delete(snapshotPath);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Gets a single snapshot by request name.
|
|
85
|
+
*/
|
|
86
|
+
async get(yamlPath: string, requestName: string): Promise<Snapshot | null> {
|
|
87
|
+
const file = await this.load(yamlPath);
|
|
88
|
+
return file?.snapshots[requestName] || null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Creates a snapshot from execution result.
|
|
93
|
+
*/
|
|
94
|
+
createSnapshot(result: ExecutionResult, config: SnapshotConfig): Snapshot {
|
|
95
|
+
const include = config.include || ['body'];
|
|
96
|
+
const snapshot: Snapshot = {
|
|
97
|
+
hash: '',
|
|
98
|
+
updatedAt: new Date().toISOString(),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
if (include.includes('status') && result.status !== undefined) {
|
|
102
|
+
snapshot.status = result.status;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (include.includes('headers') && result.headers) {
|
|
106
|
+
// Normalize headers: lowercase keys, sorted
|
|
107
|
+
snapshot.headers = this.normalizeHeaders(result.headers);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (include.includes('body') && result.body !== undefined) {
|
|
111
|
+
snapshot.body = result.body;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Generate hash from content
|
|
115
|
+
snapshot.hash = this.hash(snapshot);
|
|
116
|
+
|
|
117
|
+
return snapshot;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Normalizes headers for consistent comparison.
|
|
122
|
+
*/
|
|
123
|
+
private normalizeHeaders(headers: Record<string, string>): Record<string, string> {
|
|
124
|
+
const normalized: Record<string, string> = {};
|
|
125
|
+
const sortedKeys = Object.keys(headers).sort();
|
|
126
|
+
for (const key of sortedKeys) {
|
|
127
|
+
normalized[key.toLowerCase()] = headers[key];
|
|
128
|
+
}
|
|
129
|
+
return normalized;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Generates a hash for snapshot content.
|
|
134
|
+
*/
|
|
135
|
+
hash(content: unknown): string {
|
|
136
|
+
const str = JSON.stringify(content);
|
|
137
|
+
const hasher = new Bun.CryptoHasher('md5');
|
|
138
|
+
hasher.update(str);
|
|
139
|
+
return hasher.digest('hex').slice(0, 8);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Compares execution result against stored snapshot and optionally updates.
|
|
144
|
+
*/
|
|
145
|
+
async compareAndUpdate(
|
|
146
|
+
yamlPath: string,
|
|
147
|
+
requestName: string,
|
|
148
|
+
result: ExecutionResult,
|
|
149
|
+
config: SnapshotConfig,
|
|
150
|
+
): Promise<SnapshotCompareResult> {
|
|
151
|
+
const snapshotName = config.name || requestName;
|
|
152
|
+
const existingSnapshot = await this.get(yamlPath, snapshotName);
|
|
153
|
+
const newSnapshot = this.createSnapshot(result, config);
|
|
154
|
+
|
|
155
|
+
// No existing snapshot
|
|
156
|
+
if (!existingSnapshot) {
|
|
157
|
+
if (this.globalConfig.ci) {
|
|
158
|
+
// CI mode: fail on missing snapshot
|
|
159
|
+
return {
|
|
160
|
+
match: false,
|
|
161
|
+
isNew: true,
|
|
162
|
+
updated: false,
|
|
163
|
+
differences: [
|
|
164
|
+
{
|
|
165
|
+
path: '',
|
|
166
|
+
expected: 'snapshot',
|
|
167
|
+
received: 'none',
|
|
168
|
+
type: 'removed',
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Create new snapshot
|
|
175
|
+
await this.updateSnapshot(yamlPath, snapshotName, newSnapshot);
|
|
176
|
+
return {
|
|
177
|
+
match: true,
|
|
178
|
+
isNew: true,
|
|
179
|
+
updated: true,
|
|
180
|
+
differences: [],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Compare snapshots
|
|
185
|
+
const differ = new SnapshotDiffer(config);
|
|
186
|
+
const diffResult = differ.compare(existingSnapshot, newSnapshot);
|
|
187
|
+
|
|
188
|
+
if (diffResult.match) {
|
|
189
|
+
return {
|
|
190
|
+
match: true,
|
|
191
|
+
isNew: false,
|
|
192
|
+
updated: false,
|
|
193
|
+
differences: [],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Handle update modes
|
|
198
|
+
const updateMode = this.globalConfig.updateMode || 'none';
|
|
199
|
+
if (updateMode === 'all' || updateMode === 'failing') {
|
|
200
|
+
await this.updateSnapshot(yamlPath, snapshotName, newSnapshot);
|
|
201
|
+
return {
|
|
202
|
+
match: true,
|
|
203
|
+
isNew: false,
|
|
204
|
+
updated: true,
|
|
205
|
+
differences: diffResult.differences,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
match: false,
|
|
211
|
+
isNew: false,
|
|
212
|
+
updated: false,
|
|
213
|
+
differences: diffResult.differences,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Updates a single snapshot in the file.
|
|
219
|
+
*/
|
|
220
|
+
private async updateSnapshot(
|
|
221
|
+
yamlPath: string,
|
|
222
|
+
snapshotName: string,
|
|
223
|
+
snapshot: Snapshot,
|
|
224
|
+
): Promise<void> {
|
|
225
|
+
let file = await this.load(yamlPath);
|
|
226
|
+
if (!file) {
|
|
227
|
+
file = {
|
|
228
|
+
version: SNAPSHOT_VERSION,
|
|
229
|
+
snapshots: {},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
file.snapshots[snapshotName] = snapshot;
|
|
234
|
+
await this.save(yamlPath, file);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Merges request-level config with global config.
|
|
239
|
+
*/
|
|
240
|
+
static mergeConfig(
|
|
241
|
+
globalConfig: GlobalSnapshotConfig | undefined,
|
|
242
|
+
requestConfig: SnapshotConfig | boolean | undefined,
|
|
243
|
+
): SnapshotConfig | null {
|
|
244
|
+
// Not enabled
|
|
245
|
+
if (!requestConfig && !globalConfig?.enabled) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Simple boolean enable
|
|
250
|
+
if (requestConfig === true) {
|
|
251
|
+
return {
|
|
252
|
+
enabled: true,
|
|
253
|
+
include: globalConfig?.include || ['body'],
|
|
254
|
+
exclude: globalConfig?.exclude || [],
|
|
255
|
+
match: globalConfig?.match || {},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Detailed config
|
|
260
|
+
if (typeof requestConfig === 'object' && requestConfig.enabled !== false) {
|
|
261
|
+
return {
|
|
262
|
+
enabled: true,
|
|
263
|
+
name: requestConfig.name,
|
|
264
|
+
include: requestConfig.include || globalConfig?.include || ['body'],
|
|
265
|
+
exclude: [...(globalConfig?.exclude || []), ...(requestConfig.exclude || [])],
|
|
266
|
+
match: { ...(globalConfig?.match || {}), ...(requestConfig.match || {}) },
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Global enabled but request not specified
|
|
271
|
+
if (globalConfig?.enabled && requestConfig === undefined) {
|
|
272
|
+
return {
|
|
273
|
+
enabled: true,
|
|
274
|
+
include: globalConfig.include || ['body'],
|
|
275
|
+
exclude: globalConfig.exclude || [],
|
|
276
|
+
match: globalConfig.match || {},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Extracts body content for snapshot, applying exclusions.
|
|
286
|
+
*/
|
|
287
|
+
export function filterSnapshotBody(body: JsonValue, exclude: string[]): JsonValue {
|
|
288
|
+
if (body === null || typeof body !== 'object') {
|
|
289
|
+
return body;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const bodyExcludes = exclude.filter((p) => p.startsWith('body.')).map((p) => p.slice(5)); // Remove 'body.' prefix
|
|
293
|
+
|
|
294
|
+
if (bodyExcludes.length === 0) {
|
|
295
|
+
return body;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return filterObject(body, bodyExcludes, '');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function filterObject(obj: JsonValue, excludes: string[], currentPath: string): JsonValue {
|
|
302
|
+
if (obj === null || typeof obj !== 'object') {
|
|
303
|
+
return obj;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (Array.isArray(obj)) {
|
|
307
|
+
return obj.map((item, index) => {
|
|
308
|
+
const itemPath = currentPath ? `${currentPath}[${index}]` : `[${index}]`;
|
|
309
|
+
return filterObject(item, excludes, itemPath);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const result: Record<string, JsonValue> = {};
|
|
314
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
315
|
+
const fullPath = currentPath ? `${currentPath}.${key}` : key;
|
|
316
|
+
|
|
317
|
+
// Check if this path should be excluded
|
|
318
|
+
const shouldExclude = excludes.some((pattern) => {
|
|
319
|
+
// Exact match
|
|
320
|
+
if (pattern === fullPath) {
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
// Wildcard match (e.g., '*.timestamp' matches 'user.timestamp')
|
|
324
|
+
if (pattern.startsWith('*.')) {
|
|
325
|
+
const suffix = pattern.slice(2);
|
|
326
|
+
return fullPath.endsWith(`.${suffix}`) || fullPath === suffix;
|
|
327
|
+
}
|
|
328
|
+
// Array wildcard (e.g., '[*].id')
|
|
329
|
+
if (pattern.includes('[*]')) {
|
|
330
|
+
const regex = new RegExp(`^${pattern.replace(/\[\*\]/g, '\\[\\d+\\]')}$`);
|
|
331
|
+
return regex.test(fullPath);
|
|
332
|
+
}
|
|
333
|
+
return false;
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
if (!shouldExclude) {
|
|
337
|
+
result[key] = filterObject(value, excludes, fullPath);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return result;
|
|
342
|
+
}
|