@grest-ts/testkit 0.0.5 → 0.0.7
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/LICENSE +21 -21
- package/README.md +418 -413
- package/dist/src/runner/isolated-loader.mjs +91 -91
- package/dist/src/runner/worker-loader.mjs +49 -49
- package/dist/tsconfig.publish.tsbuildinfo +1 -1
- package/package.json +12 -12
- package/src/GGBundleTest.ts +89 -89
- package/src/GGTest.ts +318 -318
- package/src/GGTestContext.ts +74 -74
- package/src/GGTestRunner.ts +308 -308
- package/src/GGTestRuntime.ts +265 -265
- package/src/GGTestRuntimeWorker.ts +159 -159
- package/src/GGTestSharedRef.ts +116 -116
- package/src/GGTestkitExtensionsDiscovery.ts +26 -26
- package/src/IGGLocalDiscoveryServer.ts +16 -16
- package/src/callOn/GGCallOnSelector.ts +61 -61
- package/src/callOn/GGContractClass.implement.ts +43 -43
- package/src/callOn/GGTestActionForLocatorOnCall.ts +134 -134
- package/src/callOn/TestableIPC.ts +81 -81
- package/src/callOn/callOn.ts +224 -224
- package/src/callOn/registerOnCallHandler.ts +123 -123
- package/src/index-node.ts +64 -64
- package/src/mockable/GGMockable.ts +22 -22
- package/src/mockable/GGMockableCall.ts +45 -45
- package/src/mockable/GGMockableIPC.ts +20 -20
- package/src/mockable/GGMockableInterceptor.ts +44 -44
- package/src/mockable/GGMockableInterceptorsServer.ts +69 -69
- package/src/mockable/mockable.ts +71 -71
- package/src/runner/InlineRunner.ts +47 -47
- package/src/runner/IsolatedRunner.ts +179 -179
- package/src/runner/RuntimeRunner.ts +15 -15
- package/src/runner/WorkerRunner.ts +179 -179
- package/src/runner/isolated-loader.mjs +91 -91
- package/src/runner/worker-loader.mjs +49 -49
- package/src/testers/GGCallInterceptor.ts +224 -224
- package/src/testers/GGMockWith.ts +92 -92
- package/src/testers/GGSpyWith.ts +115 -115
- package/src/testers/GGTestAction.ts +332 -332
- package/src/testers/GGTestComponent.ts +16 -16
- package/src/testers/GGTestSelector.ts +223 -223
- package/src/testers/IGGTestInterceptor.ts +10 -10
- package/src/testers/IGGTestWith.ts +15 -15
- package/src/testers/RuntimeSelector.ts +151 -151
- package/src/utils/GGExpectations.ts +78 -78
- package/src/utils/GGTestError.ts +36 -36
- package/src/utils/captureStack.ts +53 -53
|
@@ -1,333 +1,333 @@
|
|
|
1
|
-
import {IGGTestWith} from "./IGGTestWith";
|
|
2
|
-
import {IGGTestInterceptor} from "./IGGTestInterceptor";
|
|
3
|
-
import {GGLog} from "@grest-ts/logger";
|
|
4
|
-
import {LOG_COLORS} from "@grest-ts/logger-console";
|
|
5
|
-
import {captureStackSourceFile} from "../utils/captureStack";
|
|
6
|
-
import {GGContext} from "@grest-ts/context";
|
|
7
|
-
import {GG_TRACE} from "@grest-ts/trace";
|
|
8
|
-
import {Raw} from "@grest-ts/schema";
|
|
9
|
-
import {DeepPartial} from "@grest-ts/common";
|
|
10
|
-
import {GGExpectations} from "../utils/GGExpectations";
|
|
11
|
-
|
|
12
|
-
interface WaitForInterceptor {
|
|
13
|
-
interceptor: IGGTestInterceptor;
|
|
14
|
-
timeout: number;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Configuration for a test action's logging behavior.
|
|
19
|
-
*/
|
|
20
|
-
export interface GGTestActionConfig {
|
|
21
|
-
/** If true, the action has no response to log (e.g., fire-and-forget WebSocket message) */
|
|
22
|
-
noResponse: boolean;
|
|
23
|
-
/** Data used for logging the action */
|
|
24
|
-
logData: {
|
|
25
|
-
/** Description shown in logs, e.g., "[POST /api/users]" or "[Config MyConfig.timeout]" */
|
|
26
|
-
message: string;
|
|
27
|
-
/** Optional context to display (e.g., auth, language) */
|
|
28
|
-
context?: any;
|
|
29
|
-
/** Optional request payload to log when executing */
|
|
30
|
-
request?: any
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Base class for test actions that can be awaited and chained with expectations.
|
|
36
|
-
*
|
|
37
|
-
* Test actions are PromiseLike objects that execute when awaited. They support
|
|
38
|
-
* attaching interceptors (mocks, log expectations, spies) that are validated
|
|
39
|
-
* during execution.
|
|
40
|
-
*
|
|
41
|
-
* @example
|
|
42
|
-
* // Simple action - just await to execute
|
|
43
|
-
* const user = await client.getUser(123);
|
|
44
|
-
*
|
|
45
|
-
* // With sync expectation - mock must be called during the action
|
|
46
|
-
* await client.createUser({name: "Alice"})
|
|
47
|
-
* .with(mockEmailService.sendWelcome.toBeCalledOnce());
|
|
48
|
-
*
|
|
49
|
-
* // With async expectation - waits for event after action completes
|
|
50
|
-
* await client.triggerAsyncJob()
|
|
51
|
-
* .waitFor(t.logs.expect("Job completed"), 10000);
|
|
52
|
-
*
|
|
53
|
-
* @typeParam T - The type returned when the action completes
|
|
54
|
-
*/
|
|
55
|
-
export abstract class GGTestAction<T> implements Promise<T> {
|
|
56
|
-
|
|
57
|
-
readonly [Symbol.toStringTag]: string = "GGTestAction";
|
|
58
|
-
|
|
59
|
-
protected readonly ctx: GGContext;
|
|
60
|
-
private readonly config: GGTestActionConfig;
|
|
61
|
-
protected readonly interceptors: IGGTestInterceptor[] = [];
|
|
62
|
-
protected readonly _waitForInterceptors: WaitForInterceptor[] = [];
|
|
63
|
-
private readonly definedInSourceFile: string;
|
|
64
|
-
|
|
65
|
-
protected readonly responseExpectations: GGExpectations<any> = new GGExpectations()
|
|
66
|
-
|
|
67
|
-
constructor(ctx: GGContext, config: GGTestActionConfig) {
|
|
68
|
-
if (ctx === undefined) throw new Error("No ctx provided!")
|
|
69
|
-
this.ctx = ctx;
|
|
70
|
-
this.config = config;
|
|
71
|
-
this.definedInSourceFile = captureStackSourceFile();
|
|
72
|
-
new GGContext("Test").run(() => {
|
|
73
|
-
GG_TRACE.init();
|
|
74
|
-
const separator = "-".repeat(100);
|
|
75
|
-
GGLog.info(this, separator)
|
|
76
|
-
GGLog.info(this,
|
|
77
|
-
this.logMsg("new", this.config.logData.message)
|
|
78
|
-
+ (this.config.logData.context ? "\n" + LOG_COLORS.bgOrange + LOG_COLORS.black
|
|
79
|
-
+ "Context: " + LOG_COLORS.reset + LOG_COLORS.orange + JSON.stringify(this.config.logData.context) + LOG_COLORS.reset : "")
|
|
80
|
-
+ "\n" + this.definedInSourceFile
|
|
81
|
-
);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
public toEqual(expectedData: Raw<T>): this {
|
|
87
|
-
this.responseExpectations.toEqual(expectedData as T);
|
|
88
|
-
return this;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
public toMatchObject(expectedData: DeepPartial<Raw<T>>): this {
|
|
92
|
-
this.responseExpectations.toMatchObject(expectedData as T);
|
|
93
|
-
return this;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
public toBeUndefined(): this {
|
|
97
|
-
this.responseExpectations.toBeUndefined();
|
|
98
|
-
return this;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
public toHaveLength(length: number): this {
|
|
102
|
-
this.responseExpectations.toHaveLength(length);
|
|
103
|
-
return this;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
public arrayToContain<Item extends T extends Array<infer R> ? R : never>(...items: Partial<Raw<Item>>[]): this {
|
|
107
|
-
this.responseExpectations.arrayToContain(...items);
|
|
108
|
-
return this;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
public arrayToContainEqual<Item extends T extends Array<infer R> ? R : never>(...items: Partial<Raw<Item>>[]): this {
|
|
112
|
-
this.responseExpectations.arrayToContainEqual(...items);
|
|
113
|
-
return this;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Attach expectations that must be satisfied during action execution.
|
|
118
|
-
*
|
|
119
|
-
* Interceptors are registered before the action runs and validated immediately after.
|
|
120
|
-
* If an expectation fails (e.g., mock not called), the test fails before checking
|
|
121
|
-
* the action's response - this surfaces the root cause of failures first.
|
|
122
|
-
*
|
|
123
|
-
* @example
|
|
124
|
-
* await client.createOrder({items: [...]})
|
|
125
|
-
* .with(mockInventory.reserve.toBeCalledOnce())
|
|
126
|
-
* .with(mockPayment.charge.toMatchObject({amount: 100}));
|
|
127
|
-
*/
|
|
128
|
-
public with(...expectations: IGGTestWith[]): this {
|
|
129
|
-
for (const expectation of expectations) {
|
|
130
|
-
// Check if this expectation requires async processing
|
|
131
|
-
if (expectation.requiresWaitFor?.()) {
|
|
132
|
-
throw new Error(
|
|
133
|
-
`This interceptor cannot be used with .with() because it handles async events ` +
|
|
134
|
-
`that occur after the HTTP response.\n\n` +
|
|
135
|
-
`SQS message processing happens asynchronously - the message is delivered to the ` +
|
|
136
|
-
`subscriber queue after the HTTP request completes.\n\n` +
|
|
137
|
-
`To verify SQS events, use one of these approaches:\n` +
|
|
138
|
-
` 1. Use .waitFor() with log verification:\n` +
|
|
139
|
-
` .waitFor(t.worker.logs.expect(/expected log message/))\n\n` +
|
|
140
|
-
` 2. Use .waitFor() with the SQS interceptor (waits for async processing):\n` +
|
|
141
|
-
` .waitFor(SqsResource.spy.toMatchObject({...}))\n`
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
this.interceptors.push(expectation.createInterceptor());
|
|
145
|
-
}
|
|
146
|
-
return this;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Attach an expectation that may be satisfied after the action completes.
|
|
151
|
-
*
|
|
152
|
-
* Use this for async side effects - when the action triggers something that
|
|
153
|
-
* happens later (e.g., background job, delayed log, async notification).
|
|
154
|
-
* The test will poll until the expectation is satisfied or timeout is reached.
|
|
155
|
-
*
|
|
156
|
-
* @param expectation - The expectation to wait for
|
|
157
|
-
* @param timeout - Max time to wait in ms (default: 5000)
|
|
158
|
-
*
|
|
159
|
-
* @example
|
|
160
|
-
* // Action returns immediately, but logs after 100ms
|
|
161
|
-
* await client.triggerBackgroundJob({id: 123})
|
|
162
|
-
* .waitFor(t.logs.expect("Job 123 completed"), 10000);
|
|
163
|
-
*/
|
|
164
|
-
public waitFor(expectation: IGGTestWith, timeout: number = 5000): this {
|
|
165
|
-
const interceptor = expectation.createInterceptor();
|
|
166
|
-
this.interceptors.push(interceptor);
|
|
167
|
-
this._waitForInterceptors.push({
|
|
168
|
-
interceptor,
|
|
169
|
-
timeout
|
|
170
|
-
});
|
|
171
|
-
return this;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
public then<TResult1 = T, TResult2 = never>(
|
|
175
|
-
onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
|
|
176
|
-
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
|
|
177
|
-
): Promise<TResult1 | TResult2> {
|
|
178
|
-
return this.execute().then(onfulfilled, onrejected);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
public catch<TResult = never>(
|
|
182
|
-
onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null
|
|
183
|
-
): Promise<T | TResult> {
|
|
184
|
-
return this.execute().catch(onrejected);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
public finally(onfinally?: (() => void) | null): Promise<T> {
|
|
188
|
-
return this.execute().finally(onfinally);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
protected async execute(): Promise<T> {
|
|
192
|
-
return this.ctx.run(async () => {
|
|
193
|
-
GG_TRACE.init();
|
|
194
|
-
GGLog.info(this,
|
|
195
|
-
this.logMsg("execute", this.config.logData.message),
|
|
196
|
-
this.config.logData.request
|
|
197
|
-
)
|
|
198
|
-
let rawResult: tActionRawData = undefined;
|
|
199
|
-
try {
|
|
200
|
-
await Promise.all(this.interceptors.map(i => i.register()));
|
|
201
|
-
rawResult = await this.executeAction()
|
|
202
|
-
await new Promise(resolve => setTimeout(resolve, 25));
|
|
203
|
-
} finally {
|
|
204
|
-
await Promise.all(this.interceptors.map(i => i.unregister()));
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const waitForSet = new Set(this._waitForInterceptors.map(w => w.interceptor));
|
|
208
|
-
|
|
209
|
-
// Validate non-wait interceptors immediately
|
|
210
|
-
for (const interceptor of this.interceptors) {
|
|
211
|
-
if (!waitForSet.has(interceptor)) {
|
|
212
|
-
await interceptor.validate();
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Check for validation errors from non-wait interceptors
|
|
217
|
-
for (const interceptor of this.interceptors) {
|
|
218
|
-
if (!waitForSet.has(interceptor)) {
|
|
219
|
-
const error = interceptor.getMockValidationError();
|
|
220
|
-
if (error) {
|
|
221
|
-
throw error;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
let result: any = undefined;
|
|
227
|
-
if (!this.config.noResponse) {
|
|
228
|
-
result = await this.processRawResponse(rawResult);
|
|
229
|
-
this.responseExpectations.check(result);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
if (this._waitForInterceptors.length > 0) {
|
|
233
|
-
await this._waitForAllInterceptors();
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// Validate wait interceptors after waiting
|
|
237
|
-
for (const {interceptor} of this._waitForInterceptors) {
|
|
238
|
-
await interceptor.validate();
|
|
239
|
-
const error = interceptor.getMockValidationError();
|
|
240
|
-
if (error) {
|
|
241
|
-
throw error;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
GGLog.info(this,
|
|
246
|
-
this.logMsg("finished", this.config.logData.message),
|
|
247
|
-
this.config.noResponse ? "Result: (void)" : rawResult
|
|
248
|
-
)
|
|
249
|
-
|
|
250
|
-
return result;
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Execute the core action (e.g., make HTTP request, send WebSocket message).
|
|
256
|
-
* Subclasses implement this with their specific action logic.
|
|
257
|
-
*
|
|
258
|
-
* @returns The raw response data (before parsing/transformation)
|
|
259
|
-
*/
|
|
260
|
-
protected abstract executeAction(): Promise<tActionRawData>;
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Process and validate the raw response from executeAction().
|
|
264
|
-
* Called AFTER mock validation, so mock errors surface first.
|
|
265
|
-
*
|
|
266
|
-
* Subclasses implement this to:
|
|
267
|
-
* - Parse the raw response into the expected type T
|
|
268
|
-
* - Check response expectations (e.g., toMatchObject, toEqual)
|
|
269
|
-
*
|
|
270
|
-
* @param result - Raw response from executeAction()
|
|
271
|
-
* @returns The parsed/validated result of type T
|
|
272
|
-
*/
|
|
273
|
-
protected processRawResponse(result: tActionRawData): Promise<T> {
|
|
274
|
-
return result;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
private async _waitForAllInterceptors(): Promise<void> {
|
|
278
|
-
const pending = new Map(
|
|
279
|
-
this._waitForInterceptors.map(w => [w.interceptor, w.timeout])
|
|
280
|
-
);
|
|
281
|
-
|
|
282
|
-
const checkInterval = 20;
|
|
283
|
-
const actionEndTime = Date.now();
|
|
284
|
-
|
|
285
|
-
return new Promise((resolve, reject) => {
|
|
286
|
-
const check = () => {
|
|
287
|
-
const elapsed = Date.now() - actionEndTime;
|
|
288
|
-
|
|
289
|
-
for (const [interceptor, timeout] of pending) {
|
|
290
|
-
if (interceptor.isCalled()) {
|
|
291
|
-
pending.delete(interceptor);
|
|
292
|
-
} else if (elapsed > timeout) {
|
|
293
|
-
reject(new Error(
|
|
294
|
-
`[Test Failed] Timeout waiting for interceptor after ${timeout}ms`
|
|
295
|
-
));
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
if (pending.size === 0) {
|
|
301
|
-
resolve();
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
setTimeout(check, checkInterval);
|
|
306
|
-
};
|
|
307
|
-
|
|
308
|
-
check();
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
private logMsg(type: "new" | "execute" | "finished", msg: string): string {
|
|
313
|
-
let pref = "";
|
|
314
|
-
let color: string = "";
|
|
315
|
-
if (type === "new") {
|
|
316
|
-
pref = "New test action "
|
|
317
|
-
color = LOG_COLORS.reset + LOG_COLORS.bgBlack + LOG_COLORS.white
|
|
318
|
-
} else if (type === "execute") {
|
|
319
|
-
pref = "Executing test action to "
|
|
320
|
-
color = LOG_COLORS.reset + LOG_COLORS.bgGray + LOG_COLORS.black
|
|
321
|
-
} else if (type === "finished") {
|
|
322
|
-
pref = "Finished test action to "
|
|
323
|
-
color = LOG_COLORS.reset + LOG_COLORS.bgGray + LOG_COLORS.black
|
|
324
|
-
}
|
|
325
|
-
return color + pref + msg + LOG_COLORS.reset;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/**
|
|
330
|
-
* Branded type for raw action response data.
|
|
331
|
-
* Used to distinguish raw responses from parsed results in the type system.
|
|
332
|
-
*/
|
|
1
|
+
import {IGGTestWith} from "./IGGTestWith";
|
|
2
|
+
import {IGGTestInterceptor} from "./IGGTestInterceptor";
|
|
3
|
+
import {GGLog} from "@grest-ts/logger";
|
|
4
|
+
import {LOG_COLORS} from "@grest-ts/logger-console";
|
|
5
|
+
import {captureStackSourceFile} from "../utils/captureStack";
|
|
6
|
+
import {GGContext} from "@grest-ts/context";
|
|
7
|
+
import {GG_TRACE} from "@grest-ts/trace";
|
|
8
|
+
import {Raw} from "@grest-ts/schema";
|
|
9
|
+
import {DeepPartial} from "@grest-ts/common";
|
|
10
|
+
import {GGExpectations} from "../utils/GGExpectations";
|
|
11
|
+
|
|
12
|
+
interface WaitForInterceptor {
|
|
13
|
+
interceptor: IGGTestInterceptor;
|
|
14
|
+
timeout: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Configuration for a test action's logging behavior.
|
|
19
|
+
*/
|
|
20
|
+
export interface GGTestActionConfig {
|
|
21
|
+
/** If true, the action has no response to log (e.g., fire-and-forget WebSocket message) */
|
|
22
|
+
noResponse: boolean;
|
|
23
|
+
/** Data used for logging the action */
|
|
24
|
+
logData: {
|
|
25
|
+
/** Description shown in logs, e.g., "[POST /api/users]" or "[Config MyConfig.timeout]" */
|
|
26
|
+
message: string;
|
|
27
|
+
/** Optional context to display (e.g., auth, language) */
|
|
28
|
+
context?: any;
|
|
29
|
+
/** Optional request payload to log when executing */
|
|
30
|
+
request?: any
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Base class for test actions that can be awaited and chained with expectations.
|
|
36
|
+
*
|
|
37
|
+
* Test actions are PromiseLike objects that execute when awaited. They support
|
|
38
|
+
* attaching interceptors (mocks, log expectations, spies) that are validated
|
|
39
|
+
* during execution.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* // Simple action - just await to execute
|
|
43
|
+
* const user = await client.getUser(123);
|
|
44
|
+
*
|
|
45
|
+
* // With sync expectation - mock must be called during the action
|
|
46
|
+
* await client.createUser({name: "Alice"})
|
|
47
|
+
* .with(mockEmailService.sendWelcome.toBeCalledOnce());
|
|
48
|
+
*
|
|
49
|
+
* // With async expectation - waits for event after action completes
|
|
50
|
+
* await client.triggerAsyncJob()
|
|
51
|
+
* .waitFor(t.logs.expect("Job completed"), 10000);
|
|
52
|
+
*
|
|
53
|
+
* @typeParam T - The type returned when the action completes
|
|
54
|
+
*/
|
|
55
|
+
export abstract class GGTestAction<T> implements Promise<T> {
|
|
56
|
+
|
|
57
|
+
readonly [Symbol.toStringTag]: string = "GGTestAction";
|
|
58
|
+
|
|
59
|
+
protected readonly ctx: GGContext;
|
|
60
|
+
private readonly config: GGTestActionConfig;
|
|
61
|
+
protected readonly interceptors: IGGTestInterceptor[] = [];
|
|
62
|
+
protected readonly _waitForInterceptors: WaitForInterceptor[] = [];
|
|
63
|
+
private readonly definedInSourceFile: string;
|
|
64
|
+
|
|
65
|
+
protected readonly responseExpectations: GGExpectations<any> = new GGExpectations()
|
|
66
|
+
|
|
67
|
+
constructor(ctx: GGContext, config: GGTestActionConfig) {
|
|
68
|
+
if (ctx === undefined) throw new Error("No ctx provided!")
|
|
69
|
+
this.ctx = ctx;
|
|
70
|
+
this.config = config;
|
|
71
|
+
this.definedInSourceFile = captureStackSourceFile();
|
|
72
|
+
new GGContext("Test").run(() => {
|
|
73
|
+
GG_TRACE.init();
|
|
74
|
+
const separator = "-".repeat(100);
|
|
75
|
+
GGLog.info(this, separator)
|
|
76
|
+
GGLog.info(this,
|
|
77
|
+
this.logMsg("new", this.config.logData.message)
|
|
78
|
+
+ (this.config.logData.context ? "\n" + LOG_COLORS.bgOrange + LOG_COLORS.black
|
|
79
|
+
+ "Context: " + LOG_COLORS.reset + LOG_COLORS.orange + JSON.stringify(this.config.logData.context) + LOG_COLORS.reset : "")
|
|
80
|
+
+ "\n" + this.definedInSourceFile
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
public toEqual(expectedData: Raw<T>): this {
|
|
87
|
+
this.responseExpectations.toEqual(expectedData as T);
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public toMatchObject(expectedData: DeepPartial<Raw<T>>): this {
|
|
92
|
+
this.responseExpectations.toMatchObject(expectedData as T);
|
|
93
|
+
return this;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
public toBeUndefined(): this {
|
|
97
|
+
this.responseExpectations.toBeUndefined();
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
public toHaveLength(length: number): this {
|
|
102
|
+
this.responseExpectations.toHaveLength(length);
|
|
103
|
+
return this;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
public arrayToContain<Item extends T extends Array<infer R> ? R : never>(...items: Partial<Raw<Item>>[]): this {
|
|
107
|
+
this.responseExpectations.arrayToContain(...items);
|
|
108
|
+
return this;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public arrayToContainEqual<Item extends T extends Array<infer R> ? R : never>(...items: Partial<Raw<Item>>[]): this {
|
|
112
|
+
this.responseExpectations.arrayToContainEqual(...items);
|
|
113
|
+
return this;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Attach expectations that must be satisfied during action execution.
|
|
118
|
+
*
|
|
119
|
+
* Interceptors are registered before the action runs and validated immediately after.
|
|
120
|
+
* If an expectation fails (e.g., mock not called), the test fails before checking
|
|
121
|
+
* the action's response - this surfaces the root cause of failures first.
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* await client.createOrder({items: [...]})
|
|
125
|
+
* .with(mockInventory.reserve.toBeCalledOnce())
|
|
126
|
+
* .with(mockPayment.charge.toMatchObject({amount: 100}));
|
|
127
|
+
*/
|
|
128
|
+
public with(...expectations: IGGTestWith[]): this {
|
|
129
|
+
for (const expectation of expectations) {
|
|
130
|
+
// Check if this expectation requires async processing
|
|
131
|
+
if (expectation.requiresWaitFor?.()) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`This interceptor cannot be used with .with() because it handles async events ` +
|
|
134
|
+
`that occur after the HTTP response.\n\n` +
|
|
135
|
+
`SQS message processing happens asynchronously - the message is delivered to the ` +
|
|
136
|
+
`subscriber queue after the HTTP request completes.\n\n` +
|
|
137
|
+
`To verify SQS events, use one of these approaches:\n` +
|
|
138
|
+
` 1. Use .waitFor() with log verification:\n` +
|
|
139
|
+
` .waitFor(t.worker.logs.expect(/expected log message/))\n\n` +
|
|
140
|
+
` 2. Use .waitFor() with the SQS interceptor (waits for async processing):\n` +
|
|
141
|
+
` .waitFor(SqsResource.spy.toMatchObject({...}))\n`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
this.interceptors.push(expectation.createInterceptor());
|
|
145
|
+
}
|
|
146
|
+
return this;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Attach an expectation that may be satisfied after the action completes.
|
|
151
|
+
*
|
|
152
|
+
* Use this for async side effects - when the action triggers something that
|
|
153
|
+
* happens later (e.g., background job, delayed log, async notification).
|
|
154
|
+
* The test will poll until the expectation is satisfied or timeout is reached.
|
|
155
|
+
*
|
|
156
|
+
* @param expectation - The expectation to wait for
|
|
157
|
+
* @param timeout - Max time to wait in ms (default: 5000)
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* // Action returns immediately, but logs after 100ms
|
|
161
|
+
* await client.triggerBackgroundJob({id: 123})
|
|
162
|
+
* .waitFor(t.logs.expect("Job 123 completed"), 10000);
|
|
163
|
+
*/
|
|
164
|
+
public waitFor(expectation: IGGTestWith, timeout: number = 5000): this {
|
|
165
|
+
const interceptor = expectation.createInterceptor();
|
|
166
|
+
this.interceptors.push(interceptor);
|
|
167
|
+
this._waitForInterceptors.push({
|
|
168
|
+
interceptor,
|
|
169
|
+
timeout
|
|
170
|
+
});
|
|
171
|
+
return this;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
public then<TResult1 = T, TResult2 = never>(
|
|
175
|
+
onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
|
|
176
|
+
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
|
|
177
|
+
): Promise<TResult1 | TResult2> {
|
|
178
|
+
return this.execute().then(onfulfilled, onrejected);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
public catch<TResult = never>(
|
|
182
|
+
onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null
|
|
183
|
+
): Promise<T | TResult> {
|
|
184
|
+
return this.execute().catch(onrejected);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
public finally(onfinally?: (() => void) | null): Promise<T> {
|
|
188
|
+
return this.execute().finally(onfinally);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
protected async execute(): Promise<T> {
|
|
192
|
+
return this.ctx.run(async () => {
|
|
193
|
+
GG_TRACE.init();
|
|
194
|
+
GGLog.info(this,
|
|
195
|
+
this.logMsg("execute", this.config.logData.message),
|
|
196
|
+
this.config.logData.request
|
|
197
|
+
)
|
|
198
|
+
let rawResult: tActionRawData = undefined;
|
|
199
|
+
try {
|
|
200
|
+
await Promise.all(this.interceptors.map(i => i.register()));
|
|
201
|
+
rawResult = await this.executeAction()
|
|
202
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
203
|
+
} finally {
|
|
204
|
+
await Promise.all(this.interceptors.map(i => i.unregister()));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const waitForSet = new Set(this._waitForInterceptors.map(w => w.interceptor));
|
|
208
|
+
|
|
209
|
+
// Validate non-wait interceptors immediately
|
|
210
|
+
for (const interceptor of this.interceptors) {
|
|
211
|
+
if (!waitForSet.has(interceptor)) {
|
|
212
|
+
await interceptor.validate();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Check for validation errors from non-wait interceptors
|
|
217
|
+
for (const interceptor of this.interceptors) {
|
|
218
|
+
if (!waitForSet.has(interceptor)) {
|
|
219
|
+
const error = interceptor.getMockValidationError();
|
|
220
|
+
if (error) {
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let result: any = undefined;
|
|
227
|
+
if (!this.config.noResponse) {
|
|
228
|
+
result = await this.processRawResponse(rawResult);
|
|
229
|
+
this.responseExpectations.check(result);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (this._waitForInterceptors.length > 0) {
|
|
233
|
+
await this._waitForAllInterceptors();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Validate wait interceptors after waiting
|
|
237
|
+
for (const {interceptor} of this._waitForInterceptors) {
|
|
238
|
+
await interceptor.validate();
|
|
239
|
+
const error = interceptor.getMockValidationError();
|
|
240
|
+
if (error) {
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
GGLog.info(this,
|
|
246
|
+
this.logMsg("finished", this.config.logData.message),
|
|
247
|
+
this.config.noResponse ? "Result: (void)" : rawResult
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
return result;
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Execute the core action (e.g., make HTTP request, send WebSocket message).
|
|
256
|
+
* Subclasses implement this with their specific action logic.
|
|
257
|
+
*
|
|
258
|
+
* @returns The raw response data (before parsing/transformation)
|
|
259
|
+
*/
|
|
260
|
+
protected abstract executeAction(): Promise<tActionRawData>;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Process and validate the raw response from executeAction().
|
|
264
|
+
* Called AFTER mock validation, so mock errors surface first.
|
|
265
|
+
*
|
|
266
|
+
* Subclasses implement this to:
|
|
267
|
+
* - Parse the raw response into the expected type T
|
|
268
|
+
* - Check response expectations (e.g., toMatchObject, toEqual)
|
|
269
|
+
*
|
|
270
|
+
* @param result - Raw response from executeAction()
|
|
271
|
+
* @returns The parsed/validated result of type T
|
|
272
|
+
*/
|
|
273
|
+
protected processRawResponse(result: tActionRawData): Promise<T> {
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private async _waitForAllInterceptors(): Promise<void> {
|
|
278
|
+
const pending = new Map(
|
|
279
|
+
this._waitForInterceptors.map(w => [w.interceptor, w.timeout])
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const checkInterval = 20;
|
|
283
|
+
const actionEndTime = Date.now();
|
|
284
|
+
|
|
285
|
+
return new Promise((resolve, reject) => {
|
|
286
|
+
const check = () => {
|
|
287
|
+
const elapsed = Date.now() - actionEndTime;
|
|
288
|
+
|
|
289
|
+
for (const [interceptor, timeout] of pending) {
|
|
290
|
+
if (interceptor.isCalled()) {
|
|
291
|
+
pending.delete(interceptor);
|
|
292
|
+
} else if (elapsed > timeout) {
|
|
293
|
+
reject(new Error(
|
|
294
|
+
`[Test Failed] Timeout waiting for interceptor after ${timeout}ms`
|
|
295
|
+
));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (pending.size === 0) {
|
|
301
|
+
resolve();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
setTimeout(check, checkInterval);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
check();
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private logMsg(type: "new" | "execute" | "finished", msg: string): string {
|
|
313
|
+
let pref = "";
|
|
314
|
+
let color: string = "";
|
|
315
|
+
if (type === "new") {
|
|
316
|
+
pref = "New test action "
|
|
317
|
+
color = LOG_COLORS.reset + LOG_COLORS.bgBlack + LOG_COLORS.white
|
|
318
|
+
} else if (type === "execute") {
|
|
319
|
+
pref = "Executing test action to "
|
|
320
|
+
color = LOG_COLORS.reset + LOG_COLORS.bgGray + LOG_COLORS.black
|
|
321
|
+
} else if (type === "finished") {
|
|
322
|
+
pref = "Finished test action to "
|
|
323
|
+
color = LOG_COLORS.reset + LOG_COLORS.bgGray + LOG_COLORS.black
|
|
324
|
+
}
|
|
325
|
+
return color + pref + msg + LOG_COLORS.reset;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Branded type for raw action response data.
|
|
331
|
+
* Used to distinguish raw responses from parsed results in the type system.
|
|
332
|
+
*/
|
|
333
333
|
export type tActionRawData = any & { tActionRawData: never }
|