velocious 1.0.643 → 1.0.644

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.
Files changed (48) hide show
  1. package/README.md +10 -1
  2. package/build/src/sync/assets/cache.d.ts +288 -0
  3. package/build/src/sync/assets/cache.d.ts.map +1 -0
  4. package/build/src/sync/assets/cache.js +883 -0
  5. package/build/src/sync/assets/types.d.ts +199 -0
  6. package/build/src/sync/assets/types.d.ts.map +1 -0
  7. package/build/src/sync/assets/types.js +56 -0
  8. package/build/src/testing/test-runner.d.ts +8 -0
  9. package/build/src/testing/test-runner.d.ts.map +1 -1
  10. package/build/src/testing/test-runner.js +49 -548
  11. package/build/src/testing/velocious-attempt-executor.d.ts +69 -0
  12. package/build/src/testing/velocious-attempt-executor.d.ts.map +1 -0
  13. package/build/src/testing/velocious-attempt-executor.js +402 -0
  14. package/build/src/testing/velocious-runner-reporter.d.ts +72 -0
  15. package/build/src/testing/velocious-runner-reporter.d.ts.map +1 -0
  16. package/build/src/testing/velocious-runner-reporter.js +152 -0
  17. package/build/src/testing/velocious-suite-hook-executor.d.ts +37 -0
  18. package/build/src/testing/velocious-suite-hook-executor.d.ts.map +1 -0
  19. package/build/src/testing/velocious-suite-hook-executor.js +66 -0
  20. package/build/src/testing/velocious-test-arguments.d.ts +34 -0
  21. package/build/src/testing/velocious-test-arguments.d.ts.map +1 -0
  22. package/build/src/testing/velocious-test-arguments.js +47 -0
  23. package/build/src/utils/sha256-bytes-hex.d.ts +7 -0
  24. package/build/src/utils/sha256-bytes-hex.d.ts.map +1 -0
  25. package/build/src/utils/sha256-bytes-hex.js +117 -0
  26. package/build/src/utils/sha256-hex.d.ts +2 -4
  27. package/build/src/utils/sha256-hex.d.ts.map +1 -1
  28. package/build/src/utils/sha256-hex.js +6 -120
  29. package/build/sync/assets/cache.js +998 -0
  30. package/build/sync/assets/types.js +62 -0
  31. package/build/testing/test-runner.js +46 -584
  32. package/build/testing/velocious-attempt-executor.js +431 -0
  33. package/build/testing/velocious-runner-reporter.js +166 -0
  34. package/build/testing/velocious-suite-hook-executor.js +71 -0
  35. package/build/testing/velocious-test-arguments.js +55 -0
  36. package/build/tsconfig.tsbuildinfo +1 -1
  37. package/build/utils/sha256-bytes-hex.js +132 -0
  38. package/build/utils/sha256-hex.js +7 -135
  39. package/package.json +1 -1
  40. package/src/sync/assets/cache.js +998 -0
  41. package/src/sync/assets/types.js +62 -0
  42. package/src/testing/test-runner.js +46 -584
  43. package/src/testing/velocious-attempt-executor.js +431 -0
  44. package/src/testing/velocious-runner-reporter.js +166 -0
  45. package/src/testing/velocious-suite-hook-executor.js +71 -0
  46. package/src/testing/velocious-test-arguments.js +55 -0
  47. package/src/utils/sha256-bytes-hex.js +132 -0
  48. package/src/utils/sha256-hex.js +7 -135
@@ -0,0 +1,431 @@
1
+ // @ts-check
2
+
3
+ import { TestDatabaseAccessRevokedError } from "../environment-handlers/base.js"
4
+ import { clearDeliveries } from "../mailer.js"
5
+ import restArgsError from "../utils/rest-args-error.js"
6
+ import { testConfig } from "./test.js"
7
+
8
+ /**
9
+ * Marks one whole-lifecycle timeout while its underlying promise keeps running.
10
+ * @typedef {Error & {velociousTestTimeout?: true}} TestTimeoutError
11
+ */
12
+
13
+ /**
14
+ * Runs one promise with a lifecycle timeout.
15
+ * @param {Promise<ReturnType<typeof JSON.parse>> | ReturnType<typeof JSON.parse>} promise - Promise or value.
16
+ * @param {number} timeoutMs - Timeout in milliseconds.
17
+ * @param {string} testDescription - Test description.
18
+ * @returns {Promise<ReturnType<typeof JSON.parse>>} - Lifecycle result.
19
+ */
20
+ function runWithTimeout(promise, timeoutMs, testDescription) {
21
+ const timeoutSeconds = (timeoutMs / 1000).toFixed(3).replace(/\.?0+$/, "")
22
+ /** @type {TestTimeoutError} */
23
+ const timeoutError = new Error(`Timed out after ${timeoutSeconds}s: ${testDescription}`)
24
+ timeoutError.velociousTestTimeout = true
25
+
26
+ return new Promise((resolve, reject) => {
27
+ const timeout = setTimeout(() => reject(timeoutError), timeoutMs)
28
+
29
+ Promise.resolve(promise).then((result) => {
30
+ clearTimeout(timeout)
31
+ resolve(result)
32
+ }).catch((error) => {
33
+ clearTimeout(timeout)
34
+ reject(error)
35
+ })
36
+ })
37
+ }
38
+
39
+ /**
40
+ * Waits for detached lifecycle cleanup up to the timeout grace period.
41
+ * @param {Promise<ReturnType<typeof JSON.parse>>} lifecycle - Detached lifecycle promise.
42
+ * @param {number} graceMs - Maximum wait.
43
+ * @returns {Promise<{settled: false} | {settled: true, status: "fulfilled"} | {settled: true, status: "rejected", reason: ReturnType<typeof JSON.parse>}>} - Settlement outcome.
44
+ */
45
+ function awaitSettledOrGrace(lifecycle, graceMs) {
46
+ return new Promise((resolve) => {
47
+ let settled = false
48
+ const graceTimer = setTimeout(() => {
49
+ if (settled) return
50
+
51
+ settled = true
52
+ resolve({settled: false})
53
+ }, graceMs)
54
+
55
+ Promise.resolve(lifecycle).then(
56
+ () => {
57
+ if (settled) return
58
+
59
+ settled = true
60
+ clearTimeout(graceTimer)
61
+ resolve({settled: true, status: "fulfilled"})
62
+ },
63
+ (reason) => {
64
+ if (settled) return
65
+
66
+ settled = true
67
+ clearTimeout(graceTimer)
68
+ resolve({settled: true, status: "rejected", reason})
69
+ }
70
+ )
71
+ })
72
+ }
73
+
74
+ /**
75
+ * Checks whether a late lifecycle stopped only because its attempt access was revoked.
76
+ * @param {ReturnType<typeof JSON.parse>} error - Lifecycle rejection.
77
+ * @returns {boolean} - Whether every contained error is expected revocation.
78
+ */
79
+ function isTestDatabaseAccessRevocation(error) {
80
+ if (error instanceof TestDatabaseAccessRevokedError) return true
81
+ if (error instanceof AggregateError) {
82
+ return error.errors.length > 0 && error.errors.every((nestedError) => isTestDatabaseAccessRevocation(nestedError))
83
+ }
84
+
85
+ return false
86
+ }
87
+
88
+ export default class VelociousAttemptExecutor {
89
+ /**
90
+ * Creates an executor for framework-owned attempt lifecycle work.
91
+ * @param {object} args - Constructor arguments.
92
+ * @param {import("./test-runner.js").default} args.testRunner - Owning Velocious runner.
93
+ */
94
+ constructor({testRunner, ...restArgs}) {
95
+ restArgsError(restArgs)
96
+ this.testRunner = testRunner
97
+ }
98
+
99
+ /**
100
+ * Executes exactly one complete Velocious-owned test attempt.
101
+ * @param {object} args - Attempt arguments.
102
+ * @param {import("./test-runner.js").AfterBeforeEachCallbackObjectType[]} args.afterEaches - Cleanup hooks.
103
+ * @param {number} args.attemptNumber - One-based attempt number.
104
+ * @param {import("./test-runner.js").AfterBeforeEachCallbackObjectType[]} args.beforeEaches - Setup hooks.
105
+ * @param {string[]} args.descriptions - Parent descriptions.
106
+ * @param {import("./velocious-test-arguments.js").TestArgs} args.testArgs - Stable test arguments.
107
+ * @param {import("./velocious-test-arguments.js").TestData} args.testData - Test registration.
108
+ * @param {string} args.testDescription - Test description.
109
+ * @param {number} [args.timeoutMs] - Whole-lifecycle timeout.
110
+ * @returns {Promise<{abortRemainingTests: boolean, consoleOutput: string, error: ReturnType<typeof JSON.parse>, failed: boolean}>} - Attempt outcome.
111
+ */
112
+ async execute({afterEaches, attemptNumber, beforeEaches, descriptions, testArgs, testData, testDescription, timeoutMs, ...restArgs}) {
113
+ restArgsError(restArgs)
114
+ const testRunner = this.testRunner
115
+ /** @type {ReturnType<typeof JSON.parse>} */
116
+ let caughtError
117
+ let failed = false
118
+ /** @type {Promise<ReturnType<typeof JSON.parse>> | undefined} */
119
+ let testLifecycle
120
+ /** @type {{pool: import("../database/pool/base.js").default, registration: import("../database/pool/base.js").TestSharedConnectionRegistration}[]} */
121
+ let testSharedConnectionRegistrations = []
122
+ let testSharedConnectionsActive = false
123
+ /** @type {import("./test-runner.js").SharedTransactionBrokerRegistration | undefined} */
124
+ let sharedTransactionBrokerRegistration
125
+ /** @type {import("./test-runner.js").SharedTransactionBrokerRegistration | undefined} */
126
+ let sharedTransactionBrokerPreparation
127
+ /** @type {import("./test-runner.js").TransactionalTenantRegistration[]} */
128
+ const transactionalTenantRegistrations = []
129
+ /** @type {import("./test-runner.js").BrowserDummyConnectionRegistration[]} */
130
+ const browserDummyConnectionRegistrations = []
131
+ const testDatabaseAccessScope = {revoked: false}
132
+ /** @type {Set<Error>} */
133
+ const recordedTimeoutCleanupErrors = new Set()
134
+ let abortRemainingTests = false
135
+ let attemptTimedOut = false
136
+ /** @type {string} */
137
+ let consoleOutput
138
+ testArgs.registerTransactionalTenant = async (args) => {
139
+ await testRunner.registerTransactionalTenant(args, transactionalTenantRegistrations)
140
+ }
141
+ const stopConsoleCapture = testRunner.startConsoleCapture({
142
+ passthrough: testConfig.consoleOutput === "live"
143
+ })
144
+ const profiler = testRunner._profiler
145
+ const profileAttempt = profiler?.startAttempt({
146
+ descriptions,
147
+ attemptNumber,
148
+ testData,
149
+ testDescription
150
+ })
151
+
152
+ try {
153
+ const runLifecycleCallback = async () => await testRunner.runWithDummyIfNeeded(testArgs, async () => {
154
+ const useTransaction = testArgs.databaseCleaning?.transaction === true
155
+ const shouldTruncate = testArgs.databaseCleaning?.truncate ?? !useTransaction
156
+ const useSharedTestConnections = useTransaction || testArgs.type == "request"
157
+ const useTestConnections = useSharedTestConnections || shouldTruncate
158
+ const runTestAttempt = async () => {
159
+ if (useSharedTestConnections) {
160
+ testSharedConnectionRegistrations = testRunner.activateTestSharedConnections()
161
+ testSharedConnectionsActive = true
162
+ }
163
+ /** @type {ReturnType<typeof JSON.parse>[]} */
164
+ const lifecycleErrors = []
165
+ let runCleanupHooks = false
166
+
167
+ try {
168
+ if (useSharedTestConnections) {
169
+ sharedTransactionBrokerPreparation = await testRunner.prepareSharedTransactionBroker()
170
+ }
171
+ runCleanupHooks = true
172
+
173
+ clearDeliveries()
174
+ await this.runBeforeEaches({beforeEaches, testArgs, testData})
175
+
176
+ if (useSharedTestConnections) {
177
+ const activeConnections = testRunner.sharedTransactionConnections({transactionsOnly: true})
178
+ if (sharedTransactionBrokerPreparation && !testRunner.sharedTransactionBrokerMatchesConnections(sharedTransactionBrokerPreparation, activeConnections)) {
179
+ testRunner.clearTestSharedConnections(testSharedConnectionRegistrations)
180
+ testSharedConnectionRegistrations = []
181
+ testSharedConnectionsActive = false
182
+ }
183
+
184
+ sharedTransactionBrokerRegistration = await testRunner.startSharedTransactionBroker(sharedTransactionBrokerPreparation, activeConnections)
185
+ sharedTransactionBrokerPreparation = undefined
186
+ if (sharedTransactionBrokerRegistration && !testSharedConnectionsActive) {
187
+ testSharedConnectionRegistrations = testRunner.activateTestSharedConnections()
188
+ testSharedConnectionsActive = true
189
+ }
190
+ }
191
+
192
+ testRunner._lastTestContext = {
193
+ fullDescription: testRunner.buildFullDescription(descriptions, testDescription),
194
+ filePath: testData.filePath ?? "<unknown>",
195
+ line: testData.line ?? 0
196
+ }
197
+ await testRunner.runProfileSpan({phase: "test body", filePath: testData.ownerFilePath ?? testData.filePath}, async () => {
198
+ await testData.function(testArgs)
199
+ })
200
+ } catch (error) {
201
+ lifecycleErrors.push(error)
202
+ }
203
+
204
+ if (runCleanupHooks) {
205
+ try {
206
+ await testRunner.getConfiguration().awaitPendingBroadcasts()
207
+ } catch (error) {
208
+ lifecycleErrors.push(error)
209
+ }
210
+
211
+ try {
212
+ if (testSharedConnectionsActive) {
213
+ testRunner.clearTestSharedConnections(testSharedConnectionRegistrations)
214
+ testSharedConnectionRegistrations = []
215
+ testSharedConnectionsActive = false
216
+ }
217
+ } catch (error) {
218
+ lifecycleErrors.push(error)
219
+ }
220
+
221
+ try {
222
+ await testRunner.stopSharedTransactionBroker(sharedTransactionBrokerRegistration || sharedTransactionBrokerPreparation)
223
+ sharedTransactionBrokerRegistration = undefined
224
+ sharedTransactionBrokerPreparation = undefined
225
+ } catch (error) {
226
+ lifecycleErrors.push(error)
227
+ }
228
+
229
+ try {
230
+ await this.runAfterEaches({afterEaches, testArgs, testData})
231
+ } catch (error) {
232
+ lifecycleErrors.push(error)
233
+ }
234
+
235
+ try {
236
+ await testRunner.cleanupTransactionalTenants(transactionalTenantRegistrations)
237
+ } catch (error) {
238
+ lifecycleErrors.push(error)
239
+ }
240
+ }
241
+
242
+ if (testSharedConnectionsActive) {
243
+ try {
244
+ testRunner.clearTestSharedConnections(testSharedConnectionRegistrations)
245
+ } catch (error) {
246
+ lifecycleErrors.push(error)
247
+ }
248
+ testSharedConnectionsActive = false
249
+ }
250
+
251
+ if (lifecycleErrors.length == 1) throw lifecycleErrors[0]
252
+ if (lifecycleErrors.length > 1) {
253
+ throw new AggregateError(lifecycleErrors, "Test lifecycle and cleanup failed", {cause: lifecycleErrors[0]})
254
+ }
255
+ }
256
+
257
+ if (useTestConnections) {
258
+ await testRunner.getConfiguration().ensureConnections({name: `Test: ${testDescription}`}, runTestAttempt)
259
+ } else {
260
+ await runTestAttempt()
261
+ }
262
+ }, browserDummyConnectionRegistrations)
263
+ const lifecycleCallback = async () => await testRunner.getConfiguration().runWithTestDatabaseAccessScope(testDatabaseAccessScope, runLifecycleCallback)
264
+ testLifecycle = profileAttempt && profiler
265
+ ? profiler.runAttempt(profileAttempt, lifecycleCallback)
266
+ : lifecycleCallback()
267
+
268
+ if (timeoutMs !== undefined) {
269
+ await runWithTimeout(testLifecycle, timeoutMs, testDescription)
270
+ } else {
271
+ await testLifecycle
272
+ }
273
+ } catch (error) {
274
+ failed = true
275
+ caughtError = error
276
+ const timedOut = Boolean(/** @type {TestTimeoutError} */ (error)?.velociousTestTimeout)
277
+ attemptTimedOut = timedOut
278
+
279
+ if (timedOut && testLifecycle) {
280
+ const emergencyCleanupErrors = []
281
+
282
+ if (profileAttempt && profiler) profiler.finishAttempt(profileAttempt, "timed-out")
283
+ const lifecycleOutcome = await awaitSettledOrGrace(testLifecycle, timeoutMs ?? 60000)
284
+
285
+ if (lifecycleOutcome.settled && lifecycleOutcome.status === "rejected") {
286
+ emergencyCleanupErrors.push(lifecycleOutcome.reason)
287
+ }
288
+
289
+ if (!lifecycleOutcome.settled) {
290
+ testDatabaseAccessScope.revoked = true
291
+ void testLifecycle.catch((cleanupError) => {
292
+ if (isTestDatabaseAccessRevocation(cleanupError)) return
293
+ testRunner.recordTimeoutCleanupFailure(cleanupError, "test lifecycle", recordedTimeoutCleanupErrors)
294
+ })
295
+ const quarantine = testRunner.quarantineBrowserDummyConnections(browserDummyConnectionRegistrations)
296
+ const quarantineOutcome = await awaitSettledOrGrace(quarantine, timeoutMs ?? 60000)
297
+ const usesBrowserTransactions = testArgs.databaseCleaning?.transaction === true
298
+ const usesBrowserTruncation = testArgs.databaseCleaning?.truncate ?? !usesBrowserTransactions
299
+
300
+ abortRemainingTests = testRunner.isBrowserTestMode()
301
+ && testRunner.hasTag(testArgs, "dummy")
302
+ && (usesBrowserTransactions || usesBrowserTruncation)
303
+
304
+ if (quarantineOutcome.settled && quarantineOutcome.status === "rejected") {
305
+ emergencyCleanupErrors.push(quarantineOutcome.reason)
306
+ } else if (!quarantineOutcome.settled) {
307
+ void quarantine.catch((cleanupError) => {
308
+ testRunner.recordTimeoutCleanupFailure(cleanupError, "browser dummy connection quarantine", recordedTimeoutCleanupErrors)
309
+ })
310
+ }
311
+ }
312
+
313
+ try {
314
+ if (testSharedConnectionsActive) {
315
+ testRunner.clearTestSharedConnections(testSharedConnectionRegistrations)
316
+ testSharedConnectionRegistrations = []
317
+ testSharedConnectionsActive = false
318
+ }
319
+ } catch (cleanupError) {
320
+ emergencyCleanupErrors.push(cleanupError)
321
+ }
322
+
323
+ const brokerCleanup = testRunner.stopSharedTransactionBroker(sharedTransactionBrokerRegistration || sharedTransactionBrokerPreparation)
324
+ const brokerCleanupOutcome = await awaitSettledOrGrace(brokerCleanup, timeoutMs ?? 60000)
325
+
326
+ if (brokerCleanupOutcome.settled && brokerCleanupOutcome.status === "rejected") {
327
+ emergencyCleanupErrors.push(brokerCleanupOutcome.reason)
328
+ } else if (!brokerCleanupOutcome.settled) {
329
+ void brokerCleanup.catch((cleanupError) => {
330
+ testRunner.recordTimeoutCleanupFailure(cleanupError, "shared transaction broker", recordedTimeoutCleanupErrors)
331
+ })
332
+ }
333
+ sharedTransactionBrokerRegistration = undefined
334
+ sharedTransactionBrokerPreparation = undefined
335
+ const emergencyCleanup = testRunner.cleanupTransactionalTenants(transactionalTenantRegistrations, {discard: true})
336
+ const emergencyCleanupOutcome = await awaitSettledOrGrace(emergencyCleanup, timeoutMs ?? 60000)
337
+
338
+ if (emergencyCleanupOutcome.settled && emergencyCleanupOutcome.status === "rejected") {
339
+ emergencyCleanupErrors.push(emergencyCleanupOutcome.reason)
340
+ } else if (!emergencyCleanupOutcome.settled) {
341
+ void emergencyCleanup.catch((cleanupError) => {
342
+ testRunner.recordTimeoutCleanupFailure(cleanupError, "transactional tenant", recordedTimeoutCleanupErrors)
343
+ })
344
+ }
345
+
346
+ if (emergencyCleanupErrors.length > 0) {
347
+ caughtError = new AggregateError(
348
+ [caughtError, ...emergencyCleanupErrors],
349
+ "Test timeout and emergency cleanup failed",
350
+ {cause: caughtError}
351
+ )
352
+ }
353
+ }
354
+
355
+ if (browserDummyConnectionRegistrations.some((registration) => registration.quarantined)) {
356
+ testDatabaseAccessScope.revoked = true
357
+ abortRemainingTests = true
358
+ }
359
+ } finally {
360
+ testDatabaseAccessScope.revoked = true
361
+ consoleOutput = stopConsoleCapture()
362
+
363
+ if (profileAttempt && profiler) {
364
+ profiler.finishAttempt(profileAttempt, failed
365
+ ? (attemptTimedOut ? "timed-out" : "failed")
366
+ : "passed")
367
+ }
368
+ }
369
+
370
+ return {
371
+ abortRemainingTests,
372
+ consoleOutput,
373
+ error: caughtError,
374
+ failed
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Runs before-each hooks in inherited declaration order.
380
+ * @param {object} args - Hook arguments.
381
+ * @param {import("./test-runner.js").AfterBeforeEachCallbackObjectType[]} args.beforeEaches - Setup hooks.
382
+ * @param {import("./velocious-test-arguments.js").TestArgs} args.testArgs - Stable test arguments.
383
+ * @param {import("./velocious-test-arguments.js").TestData} args.testData - Test registration.
384
+ * @returns {Promise<void>} - Resolves after all setup hooks complete.
385
+ */
386
+ async runBeforeEaches({beforeEaches, testArgs, testData}) {
387
+ for (const hook of beforeEaches) {
388
+ await this.testRunner.runProfileSpan({
389
+ phase: "beforeEach",
390
+ declarationIndex: hook.declarationIndex,
391
+ declarationScopeId: hook.declarationScopeId,
392
+ filePath: hook.ownerFilePath
393
+ }, async () => {
394
+ await hook.callback({configuration: this.testRunner.getConfiguration(), testArgs, testData})
395
+ })
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Runs every after-each hook while preserving all failures.
401
+ * @param {object} args - Hook arguments.
402
+ * @param {import("./test-runner.js").AfterBeforeEachCallbackObjectType[]} args.afterEaches - Cleanup hooks.
403
+ * @param {import("./velocious-test-arguments.js").TestArgs} args.testArgs - Stable test arguments.
404
+ * @param {import("./velocious-test-arguments.js").TestData} args.testData - Test registration.
405
+ * @returns {Promise<void>} - Resolves after every cleanup hook settles.
406
+ */
407
+ async runAfterEaches({afterEaches, testArgs, testData}) {
408
+ /** @type {ReturnType<typeof JSON.parse>[]} */
409
+ const errors = []
410
+
411
+ for (const hook of afterEaches) {
412
+ try {
413
+ await this.testRunner.runProfileSpan({
414
+ phase: "afterEach",
415
+ declarationIndex: hook.declarationIndex,
416
+ declarationScopeId: hook.declarationScopeId,
417
+ filePath: hook.ownerFilePath
418
+ }, async () => {
419
+ await hook.callback({configuration: this.testRunner.getConfiguration(), testArgs, testData})
420
+ })
421
+ } catch (error) {
422
+ errors.push(error)
423
+ }
424
+ }
425
+
426
+ if (errors.length == 1) throw errors[0]
427
+ if (errors.length > 1) {
428
+ throw new AggregateError(errors, "Multiple afterEach hooks failed", {cause: errors[0]})
429
+ }
430
+ }
431
+ }
@@ -0,0 +1,166 @@
1
+ // @ts-check
2
+
3
+ import { addTrackedStackToError } from "../utils/with-tracked-stack.js"
4
+ import BacktraceCleaner from "../utils/backtrace-cleaner-node.js"
5
+ import picocolors from "picocolors"
6
+ import restArgsError from "../utils/rest-args-error.js"
7
+ import { testEvents } from "./test.js"
8
+
9
+ export default class VelociousRunnerReporter {
10
+ /**
11
+ * Creates the legacy event and result projection adapter.
12
+ * @param {object} args - Constructor arguments.
13
+ * @param {import("./test-runner.js").default} args.testRunner - Owning Velocious runner.
14
+ */
15
+ constructor({testRunner, ...restArgs}) {
16
+ restArgsError(restArgs)
17
+ this.testRunner = testRunner
18
+ }
19
+
20
+ /**
21
+ * Projects one completed attempt into legacy events and final result accounting.
22
+ * Retry eligibility is decided by the caller before this method runs.
23
+ * @param {object} args - Completed attempt and retry metadata.
24
+ * @param {import("./test-runner.js").AttemptConsoleOutput[]} args.attemptConsoleOutputs - Captured output across attempts.
25
+ * @param {number} args.attemptNumber - Current one-based attempt.
26
+ * @param {string[]} args.descriptions - Parent description stack.
27
+ * @param {ReturnType<typeof JSON.parse>} args.error - Raw thrown or rejected value.
28
+ * @param {boolean} args.failed - Whether the attempt failed, independently of error truthiness.
29
+ * @param {string} args.leftPadding - Console indentation.
30
+ * @param {number} args.retriesUsed - Retry count consumed after this attempt.
31
+ * @param {number} args.retryCount - Configured retry limit.
32
+ * @param {import("./velocious-test-arguments.js").TestArgs} args.testArgs - Stable test arguments.
33
+ * @param {import("./velocious-test-arguments.js").TestData} args.testData - Test registration.
34
+ * @param {string} args.testDescription - Test description.
35
+ * @param {boolean} args.willRetry - Whether the legacy loop will run another attempt.
36
+ * @returns {Promise<void>} - Resolves after all legacy listeners complete.
37
+ */
38
+ async reportAttempt({attemptConsoleOutputs, attemptNumber, descriptions, error, failed, leftPadding, retriesUsed, retryCount, testArgs, testData, testDescription, willRetry, ...restArgs}) {
39
+ restArgsError(restArgs)
40
+ const testRunner = this.testRunner
41
+
42
+ if (!failed) testRunner._successfulTests++
43
+
44
+ if (failed) {
45
+ await this.emitEvent("testAttemptFailed", {
46
+ configuration: testRunner.getConfiguration(),
47
+ descriptions,
48
+ error,
49
+ attemptNumber,
50
+ nextAttempt: willRetry ? attemptNumber + 1 : undefined,
51
+ retriesUsed,
52
+ retryCount,
53
+ testArgs,
54
+ testData,
55
+ testDescription,
56
+ testRunner,
57
+ willRetry
58
+ })
59
+ }
60
+
61
+ if (willRetry) {
62
+ console.warn(picocolors.red(`${leftPadding} Retrying (${retriesUsed}/${retryCount}) after error: ${error instanceof Error ? error.message : String(error)}`))
63
+ await this.emitEvent("testRetrying", {
64
+ configuration: testRunner.getConfiguration(),
65
+ descriptions,
66
+ error,
67
+ nextAttempt: attemptNumber + 1,
68
+ retriesUsed,
69
+ retryCount,
70
+ testArgs,
71
+ testData,
72
+ testDescription,
73
+ testRunner
74
+ })
75
+ }
76
+
77
+ if (attemptNumber > 1) {
78
+ await this.emitEvent("testRetried", {
79
+ configuration: testRunner.getConfiguration(),
80
+ descriptions,
81
+ error,
82
+ attemptNumber,
83
+ retriesUsed,
84
+ retryCount,
85
+ testArgs,
86
+ testData,
87
+ testDescription,
88
+ testRunner
89
+ })
90
+ }
91
+
92
+ if (failed && !willRetry) {
93
+ await this.reportFailedTest({attemptConsoleOutputs, descriptions, error, leftPadding, testArgs, testData, testDescription})
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Records and emits one final failed test result.
99
+ * @param {object} args - Final failure metadata.
100
+ * @param {import("./test-runner.js").AttemptConsoleOutput[]} args.attemptConsoleOutputs - Captured output across attempts.
101
+ * @param {string[]} args.descriptions - Parent description stack.
102
+ * @param {ReturnType<typeof JSON.parse>} args.error - Raw final failure.
103
+ * @param {string} args.leftPadding - Console indentation.
104
+ * @param {import("./velocious-test-arguments.js").TestArgs} args.testArgs - Stable test arguments.
105
+ * @param {import("./velocious-test-arguments.js").TestData} args.testData - Test registration.
106
+ * @param {string} args.testDescription - Test description.
107
+ * @returns {Promise<void>} - Resolves after the final-failure listener completes.
108
+ */
109
+ async reportFailedTest({attemptConsoleOutputs, descriptions, error, leftPadding, testArgs, testData, testDescription}) {
110
+ const testRunner = this.testRunner
111
+ const consoleOutput = testRunner.buildConsoleOutput(attemptConsoleOutputs)
112
+
113
+ if (error instanceof Error) {
114
+ console.error(picocolors.red(`${leftPadding} Test failed: ${error.message}`))
115
+ addTrackedStackToError(error)
116
+
117
+ const backtraceCleaner = new BacktraceCleaner(error)
118
+ const cleanedStack = backtraceCleaner.getCleanedStack()
119
+ const stackLines = cleanedStack?.split("\n")
120
+
121
+ if (stackLines) {
122
+ for (const stackLine of stackLines) {
123
+ console.error(picocolors.red(`${leftPadding} ${stackLine}`))
124
+ }
125
+ }
126
+ } else {
127
+ console.error(picocolors.red(`${leftPadding} Test failed with a ${typeof error}: ${String(error)}`))
128
+ }
129
+
130
+ testRunner.printFailedConsoleOutput({consoleOutput, leftPadding})
131
+ testRunner._failedTests++
132
+ testRunner._failedTestDetails.push({
133
+ fullDescription: testRunner.buildFullDescription(descriptions, testDescription),
134
+ filePath: testData.filePath,
135
+ line: testData.line,
136
+ error,
137
+ consoleOutput: consoleOutput || undefined
138
+ })
139
+
140
+ await this.emitEvent("testFailed", {
141
+ configuration: testRunner.getConfiguration(),
142
+ descriptions,
143
+ error,
144
+ testArgs,
145
+ testData,
146
+ testDescription,
147
+ testRunner
148
+ })
149
+
150
+ testRunner.printRerunCommand({descriptions, testDescription, testData, leftPadding})
151
+ }
152
+
153
+ /**
154
+ * Emits one legacy event and awaits listeners in registration order.
155
+ * @param {string} eventName - Event name.
156
+ * @param {object} payload - Event payload.
157
+ * @returns {Promise<void>} - Resolves when all listeners complete.
158
+ */
159
+ async emitEvent(eventName, payload) {
160
+ const listeners = testEvents.listeners(eventName)
161
+
162
+ for (const listener of listeners) {
163
+ await listener(payload)
164
+ }
165
+ }
166
+ }
@@ -0,0 +1,71 @@
1
+ // @ts-check
2
+
3
+ import restArgsError from "../utils/rest-args-error.js"
4
+
5
+ export default class VelociousSuiteHookExecutor {
6
+ /**
7
+ * Creates an executor for Velocious suite hooks.
8
+ * @param {object} args - Constructor arguments.
9
+ * @param {import("./test-runner.js").default} args.testRunner - Owning Velocious runner.
10
+ */
11
+ constructor({testRunner, ...restArgs}) {
12
+ restArgsError(restArgs)
13
+ this.testRunner = testRunner
14
+ }
15
+
16
+ /**
17
+ * Runs suite setup hooks in declaration order.
18
+ * @param {object} args - Hook execution arguments.
19
+ * @param {import("./test-runner.js").BeforeAfterAllCallbackObjectType[]} args.hooks - Profiled setup hooks.
20
+ * @returns {Promise<void>} - Resolves after every setup hook completes.
21
+ */
22
+ async runBeforeAlls({hooks, ...restArgs}) {
23
+ restArgsError(restArgs)
24
+
25
+ for (const hook of hooks) {
26
+ await this.runHook(hook, "beforeAll")
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Runs every suite teardown hook in reverse declaration order.
32
+ * @param {object} args - Hook execution arguments.
33
+ * @param {import("./test-runner.js").BeforeAfterAllCallbackObjectType[]} args.hooks - Profiled teardown hooks.
34
+ * @returns {Promise<void>} - Resolves after every teardown hook settles.
35
+ */
36
+ async runAfterAlls({hooks, ...restArgs}) {
37
+ restArgsError(restArgs)
38
+ /** @type {ReturnType<typeof JSON.parse>[]} */
39
+ const errors = []
40
+
41
+ for (const hook of [...hooks].reverse()) {
42
+ try {
43
+ await this.runHook(hook, "afterAll")
44
+ } catch (error) {
45
+ errors.push(error)
46
+ }
47
+ }
48
+
49
+ if (errors.length == 1) throw errors[0]
50
+ if (errors.length > 1) {
51
+ throw new AggregateError(errors, "Multiple afterAll hooks failed", {cause: errors[0]})
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Runs one suite hook with its Velocious profiler attribution.
57
+ * @param {import("./test-runner.js").BeforeAfterAllCallbackObjectType} hook - Hook registration.
58
+ * @param {"beforeAll" | "afterAll"} phase - Profiler phase.
59
+ * @returns {Promise<void>} - Resolves when the hook completes.
60
+ */
61
+ async runHook(hook, phase) {
62
+ await this.testRunner.runProfileSpan({
63
+ phase,
64
+ declarationIndex: hook.declarationIndex,
65
+ declarationScopeId: hook.declarationScopeId,
66
+ filePath: hook.ownerFilePath
67
+ }, async () => {
68
+ await hook.callback({configuration: this.testRunner.getConfiguration()})
69
+ })
70
+ }
71
+ }