@curl-runner/cli 1.10.0 → 1.12.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 +92 -0
- package/src/executor/request-executor.ts +51 -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 +190 -0
- package/src/utils/condition-evaluator.test.ts +415 -0
- package/src/utils/condition-evaluator.ts +327 -0
- package/src/utils/logger.ts +67 -4
|
@@ -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
|
+
}
|
package/src/types/config.ts
CHANGED
|
@@ -57,6 +57,87 @@ export type FormDataConfig = Record<string, FormFieldValue>;
|
|
|
57
57
|
*/
|
|
58
58
|
export type StoreConfig = Record<string, string>;
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Operators for conditional expressions.
|
|
62
|
+
*/
|
|
63
|
+
export type ConditionOperator =
|
|
64
|
+
| '=='
|
|
65
|
+
| '!='
|
|
66
|
+
| '>'
|
|
67
|
+
| '<'
|
|
68
|
+
| '>='
|
|
69
|
+
| '<='
|
|
70
|
+
| 'contains'
|
|
71
|
+
| 'matches'
|
|
72
|
+
| 'exists'
|
|
73
|
+
| 'not-exists';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A single condition expression comparing a store value against an expected value.
|
|
77
|
+
*
|
|
78
|
+
* Examples:
|
|
79
|
+
* - `{ left: "store.status", operator: "==", right: 200 }`
|
|
80
|
+
* - `{ left: "store.userId", operator: "exists" }`
|
|
81
|
+
* - `{ left: "store.body.type", operator: "contains", right: "user" }`
|
|
82
|
+
*/
|
|
83
|
+
export interface ConditionExpression {
|
|
84
|
+
/** Left operand - typically a store path like "store.status" or "store.body.id" */
|
|
85
|
+
left: string;
|
|
86
|
+
/** Comparison operator */
|
|
87
|
+
operator: ConditionOperator;
|
|
88
|
+
/** Right operand - the value to compare against. Optional for exists/not-exists. */
|
|
89
|
+
right?: string | number | boolean;
|
|
90
|
+
/** Case-sensitive comparison for string operators. Default: false (case-insensitive) */
|
|
91
|
+
caseSensitive?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Configuration for conditional request execution.
|
|
96
|
+
* Supports single conditions, AND (all), and OR (any) compound conditions.
|
|
97
|
+
*
|
|
98
|
+
* Examples:
|
|
99
|
+
* ```yaml
|
|
100
|
+
* # Single condition
|
|
101
|
+
* when:
|
|
102
|
+
* left: store.status
|
|
103
|
+
* operator: "=="
|
|
104
|
+
* right: 200
|
|
105
|
+
*
|
|
106
|
+
* # AND condition (all must be true)
|
|
107
|
+
* when:
|
|
108
|
+
* all:
|
|
109
|
+
* - left: store.status
|
|
110
|
+
* operator: "=="
|
|
111
|
+
* right: 200
|
|
112
|
+
* - left: store.userId
|
|
113
|
+
* operator: exists
|
|
114
|
+
*
|
|
115
|
+
* # OR condition (any must be true)
|
|
116
|
+
* when:
|
|
117
|
+
* any:
|
|
118
|
+
* - left: store.type
|
|
119
|
+
* operator: "=="
|
|
120
|
+
* right: "admin"
|
|
121
|
+
* - left: store.type
|
|
122
|
+
* operator: "=="
|
|
123
|
+
* right: "superuser"
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
export interface WhenCondition {
|
|
127
|
+
/** All conditions must be true (AND logic) */
|
|
128
|
+
all?: ConditionExpression[];
|
|
129
|
+
/** Any condition must be true (OR logic) */
|
|
130
|
+
any?: ConditionExpression[];
|
|
131
|
+
/** Single condition - left operand */
|
|
132
|
+
left?: string;
|
|
133
|
+
/** Single condition - operator */
|
|
134
|
+
operator?: ConditionOperator;
|
|
135
|
+
/** Single condition - right operand */
|
|
136
|
+
right?: string | number | boolean;
|
|
137
|
+
/** Case-sensitive comparison for string operators. Default: false */
|
|
138
|
+
caseSensitive?: boolean;
|
|
139
|
+
}
|
|
140
|
+
|
|
60
141
|
/**
|
|
61
142
|
* SSL/TLS certificate configuration options.
|
|
62
143
|
*
|
|
@@ -140,6 +221,30 @@ export interface RequestConfig {
|
|
|
140
221
|
* contentType: headers.content-type
|
|
141
222
|
*/
|
|
142
223
|
store?: StoreConfig;
|
|
224
|
+
/**
|
|
225
|
+
* Conditional execution - skip/run request based on previous results.
|
|
226
|
+
* Only works in sequential execution mode.
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* # Object syntax
|
|
230
|
+
* when:
|
|
231
|
+
* left: store.status
|
|
232
|
+
* operator: "=="
|
|
233
|
+
* right: 200
|
|
234
|
+
*
|
|
235
|
+
* # String shorthand
|
|
236
|
+
* when: "store.status == 200"
|
|
237
|
+
*
|
|
238
|
+
* # Compound conditions
|
|
239
|
+
* when:
|
|
240
|
+
* all:
|
|
241
|
+
* - left: store.userId
|
|
242
|
+
* operator: exists
|
|
243
|
+
* - left: store.status
|
|
244
|
+
* operator: "<"
|
|
245
|
+
* right: 400
|
|
246
|
+
*/
|
|
247
|
+
when?: WhenCondition | string;
|
|
143
248
|
expect?: {
|
|
144
249
|
failure?: boolean; // If true, expect the request to fail (for negative testing)
|
|
145
250
|
status?: number | number[];
|
|
@@ -147,6 +252,11 @@ export interface RequestConfig {
|
|
|
147
252
|
body?: JsonValue;
|
|
148
253
|
responseTime?: string; // Response time validation like "< 1000", "> 500, < 2000"
|
|
149
254
|
};
|
|
255
|
+
/**
|
|
256
|
+
* Snapshot configuration for this request.
|
|
257
|
+
* Use `true` to enable with defaults, or provide detailed config.
|
|
258
|
+
*/
|
|
259
|
+
snapshot?: SnapshotConfig | boolean;
|
|
150
260
|
sourceOutputConfig?: {
|
|
151
261
|
verbose?: boolean;
|
|
152
262
|
showHeaders?: boolean;
|
|
@@ -215,6 +325,11 @@ export interface GlobalConfig {
|
|
|
215
325
|
* Automatically re-runs requests when YAML files change.
|
|
216
326
|
*/
|
|
217
327
|
watch?: WatchConfig;
|
|
328
|
+
/**
|
|
329
|
+
* Snapshot testing configuration.
|
|
330
|
+
* Saves response snapshots and compares future runs against them.
|
|
331
|
+
*/
|
|
332
|
+
snapshot?: GlobalSnapshotConfig;
|
|
218
333
|
variables?: Record<string, string>;
|
|
219
334
|
output?: {
|
|
220
335
|
verbose?: boolean;
|
|
@@ -252,12 +367,19 @@ export interface ExecutionResult {
|
|
|
252
367
|
firstByte?: number;
|
|
253
368
|
download?: number;
|
|
254
369
|
};
|
|
370
|
+
/** Snapshot comparison result (if snapshot testing enabled). */
|
|
371
|
+
snapshotResult?: SnapshotCompareResult;
|
|
372
|
+
/** Whether this request was skipped due to a `when` condition. */
|
|
373
|
+
skipped?: boolean;
|
|
374
|
+
/** Reason the request was skipped (condition that failed). */
|
|
375
|
+
skipReason?: string;
|
|
255
376
|
}
|
|
256
377
|
|
|
257
378
|
export interface ExecutionSummary {
|
|
258
379
|
total: number;
|
|
259
380
|
successful: number;
|
|
260
381
|
failed: number;
|
|
382
|
+
skipped: number;
|
|
261
383
|
duration: number;
|
|
262
384
|
results: ExecutionResult[];
|
|
263
385
|
}
|
|
@@ -280,3 +402,71 @@ export interface WatchConfig {
|
|
|
280
402
|
/** Clear screen between runs. Default: true */
|
|
281
403
|
clear?: boolean;
|
|
282
404
|
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Configuration for snapshot testing.
|
|
408
|
+
* Snapshots save response data and compare future runs against them.
|
|
409
|
+
*/
|
|
410
|
+
export interface SnapshotConfig {
|
|
411
|
+
/** Enable snapshot testing for this request. */
|
|
412
|
+
enabled?: boolean;
|
|
413
|
+
/** Custom snapshot name (defaults to request name). */
|
|
414
|
+
name?: string;
|
|
415
|
+
/** What to include in snapshot. Default: ['body'] */
|
|
416
|
+
include?: ('body' | 'status' | 'headers')[];
|
|
417
|
+
/** Paths to exclude from comparison (e.g., 'body.timestamp'). */
|
|
418
|
+
exclude?: string[];
|
|
419
|
+
/** Match rules for dynamic values (path -> '*' or 'regex:pattern'). */
|
|
420
|
+
match?: Record<string, string>;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Global snapshot configuration.
|
|
425
|
+
*/
|
|
426
|
+
export interface GlobalSnapshotConfig extends SnapshotConfig {
|
|
427
|
+
/** Directory for snapshot files. Default: '__snapshots__' */
|
|
428
|
+
dir?: string;
|
|
429
|
+
/** Update mode: 'none' | 'all' | 'failing'. Default: 'none' */
|
|
430
|
+
updateMode?: 'none' | 'all' | 'failing';
|
|
431
|
+
/** CI mode: fail if snapshot is missing. Default: false */
|
|
432
|
+
ci?: boolean;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Stored snapshot data for a single request.
|
|
437
|
+
*/
|
|
438
|
+
export interface Snapshot {
|
|
439
|
+
status?: number;
|
|
440
|
+
headers?: Record<string, string>;
|
|
441
|
+
body?: JsonValue;
|
|
442
|
+
hash: string;
|
|
443
|
+
updatedAt: string;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Snapshot file format.
|
|
448
|
+
*/
|
|
449
|
+
export interface SnapshotFile {
|
|
450
|
+
version: number;
|
|
451
|
+
snapshots: Record<string, Snapshot>;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Result of comparing a response against a snapshot.
|
|
456
|
+
*/
|
|
457
|
+
export interface SnapshotDiff {
|
|
458
|
+
path: string;
|
|
459
|
+
expected: unknown;
|
|
460
|
+
received: unknown;
|
|
461
|
+
type: 'added' | 'removed' | 'changed' | 'type_mismatch';
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Result of snapshot comparison.
|
|
466
|
+
*/
|
|
467
|
+
export interface SnapshotCompareResult {
|
|
468
|
+
match: boolean;
|
|
469
|
+
isNew: boolean;
|
|
470
|
+
updated: boolean;
|
|
471
|
+
differences: SnapshotDiff[];
|
|
472
|
+
}
|