velocious 1.0.642 → 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 (96) hide show
  1. package/README.md +25 -2
  2. package/build/authorization/base-resource.js +18 -5
  3. package/build/configuration-types.js +34 -2
  4. package/build/database/record/index.js +42 -11
  5. package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +17 -3
  6. package/build/frontend-model-controller.js +10 -5
  7. package/build/frontend-model-resource/base-resource.js +95 -41
  8. package/build/frontend-models/base.js +1 -1
  9. package/build/src/authorization/base-resource.d.ts +19 -6
  10. package/build/src/authorization/base-resource.d.ts.map +1 -1
  11. package/build/src/authorization/base-resource.js +16 -6
  12. package/build/src/configuration-types.d.ts +72 -5
  13. package/build/src/configuration-types.d.ts.map +1 -1
  14. package/build/src/configuration-types.js +31 -3
  15. package/build/src/database/record/index.d.ts +44 -46
  16. package/build/src/database/record/index.d.ts.map +1 -1
  17. package/build/src/database/record/index.js +39 -12
  18. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
  19. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +19 -4
  20. package/build/src/frontend-model-controller.d.ts +1 -1
  21. package/build/src/frontend-model-controller.d.ts.map +1 -1
  22. package/build/src/frontend-model-controller.js +9 -6
  23. package/build/src/frontend-model-resource/base-resource.d.ts +66 -32
  24. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  25. package/build/src/frontend-model-resource/base-resource.js +89 -42
  26. package/build/src/frontend-models/base.d.ts +1 -0
  27. package/build/src/frontend-models/base.d.ts.map +1 -1
  28. package/build/src/frontend-models/base.js +2 -2
  29. package/build/src/sync/assets/cache.d.ts +288 -0
  30. package/build/src/sync/assets/cache.d.ts.map +1 -0
  31. package/build/src/sync/assets/cache.js +883 -0
  32. package/build/src/sync/assets/types.d.ts +199 -0
  33. package/build/src/sync/assets/types.d.ts.map +1 -0
  34. package/build/src/sync/assets/types.js +56 -0
  35. package/build/src/sync/sync-api-controller.d.ts.map +1 -1
  36. package/build/src/sync/sync-api-controller.js +6 -5
  37. package/build/src/sync/sync-envelope-replay-service.d.ts +1 -1
  38. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  39. package/build/src/sync/sync-envelope-replay-service.js +3 -2
  40. package/build/src/sync/sync-resource-base.d.ts +2 -0
  41. package/build/src/sync/sync-resource-base.d.ts.map +1 -1
  42. package/build/src/sync/sync-resource-base.js +3 -1
  43. package/build/src/testing/test-runner.d.ts +8 -0
  44. package/build/src/testing/test-runner.d.ts.map +1 -1
  45. package/build/src/testing/test-runner.js +49 -548
  46. package/build/src/testing/velocious-attempt-executor.d.ts +69 -0
  47. package/build/src/testing/velocious-attempt-executor.d.ts.map +1 -0
  48. package/build/src/testing/velocious-attempt-executor.js +402 -0
  49. package/build/src/testing/velocious-runner-reporter.d.ts +72 -0
  50. package/build/src/testing/velocious-runner-reporter.d.ts.map +1 -0
  51. package/build/src/testing/velocious-runner-reporter.js +152 -0
  52. package/build/src/testing/velocious-suite-hook-executor.d.ts +37 -0
  53. package/build/src/testing/velocious-suite-hook-executor.d.ts.map +1 -0
  54. package/build/src/testing/velocious-suite-hook-executor.js +66 -0
  55. package/build/src/testing/velocious-test-arguments.d.ts +34 -0
  56. package/build/src/testing/velocious-test-arguments.d.ts.map +1 -0
  57. package/build/src/testing/velocious-test-arguments.js +47 -0
  58. package/build/src/utils/sha256-bytes-hex.d.ts +7 -0
  59. package/build/src/utils/sha256-bytes-hex.d.ts.map +1 -0
  60. package/build/src/utils/sha256-bytes-hex.js +117 -0
  61. package/build/src/utils/sha256-hex.d.ts +2 -4
  62. package/build/src/utils/sha256-hex.d.ts.map +1 -1
  63. package/build/src/utils/sha256-hex.js +6 -120
  64. package/build/sync/assets/cache.js +998 -0
  65. package/build/sync/assets/types.js +62 -0
  66. package/build/sync/sync-api-controller.js +5 -5
  67. package/build/sync/sync-envelope-replay-service.js +2 -1
  68. package/build/sync/sync-resource-base.js +3 -0
  69. package/build/testing/test-runner.js +46 -584
  70. package/build/testing/velocious-attempt-executor.js +431 -0
  71. package/build/testing/velocious-runner-reporter.js +166 -0
  72. package/build/testing/velocious-suite-hook-executor.js +71 -0
  73. package/build/testing/velocious-test-arguments.js +55 -0
  74. package/build/tsconfig.tsbuildinfo +1 -1
  75. package/build/utils/sha256-bytes-hex.js +132 -0
  76. package/build/utils/sha256-hex.js +7 -135
  77. package/package.json +1 -1
  78. package/src/authorization/base-resource.js +18 -5
  79. package/src/configuration-types.js +34 -2
  80. package/src/database/record/index.js +42 -11
  81. package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +17 -3
  82. package/src/frontend-model-controller.js +10 -5
  83. package/src/frontend-model-resource/base-resource.js +95 -41
  84. package/src/frontend-models/base.js +1 -1
  85. package/src/sync/assets/cache.js +998 -0
  86. package/src/sync/assets/types.js +62 -0
  87. package/src/sync/sync-api-controller.js +5 -5
  88. package/src/sync/sync-envelope-replay-service.js +2 -1
  89. package/src/sync/sync-resource-base.js +3 -0
  90. package/src/testing/test-runner.js +46 -584
  91. package/src/testing/velocious-attempt-executor.js +431 -0
  92. package/src/testing/velocious-runner-reporter.js +166 -0
  93. package/src/testing/velocious-suite-hook-executor.js +71 -0
  94. package/src/testing/velocious-test-arguments.js +55 -0
  95. package/src/utils/sha256-bytes-hex.js +132 -0
  96. package/src/utils/sha256-hex.js +7 -135
@@ -1,22 +1,22 @@
1
1
  // @ts-check
2
2
 
3
- import {addTrackedStackToError} from "../utils/with-tracked-stack.js"
4
3
  import fs from "node:fs/promises"
5
4
  import path from "path"
6
5
  import {format} from "node:util"
7
6
  import {AsyncLocalStorage} from "node:async_hooks"
8
7
  import Application from "../../src/application.js"
9
- import BacktraceCleaner from "../utils/backtrace-cleaner-node.js"
10
- import {TestDatabaseAccessRevokedError} from "../environment-handlers/base.js"
11
8
  import RequestClient from "./request-client.js"
12
9
  import picocolors from "picocolors"
13
10
  import restArgsError from "../utils/rest-args-error.js"
14
- import {testConfig, testEvents, tests} from "./test.js"
11
+ import {testConfig, tests} from "./test.js"
15
12
  import {pathToFileURL} from "url"
16
- import {clearDeliveries} from "../mailer.js"
17
13
  import SharedTransactionBroker from "./shared-transaction-broker.js"
18
14
  import { SHARED_TRANSACTION_BROKER_ENV } from "./shared-transaction-proxy-driver.js"
19
15
  import {synchronizeTestingPackageTests} from "./testing-package-adapter.js"
16
+ import VelociousAttemptExecutor from "./velocious-attempt-executor.js"
17
+ import VelociousRunnerReporter from "./velocious-runner-reporter.js"
18
+ import VelociousSuiteHookExecutor from "./velocious-suite-hook-executor.js"
19
+ import VelociousTestArguments from "./velocious-test-arguments.js"
20
20
 
21
21
  /**
22
22
  * ConsoleMethodName type.
@@ -120,9 +120,8 @@ import {synchronizeTestingPackageTests} from "./testing-package-adapter.js"
120
120
  * @property {Record<string, TestsArgument>} subs - Optional child nodes. Each item is another `Node`, allowing recursion.
121
121
  */
122
122
  /**
123
- * Marks the error thrown by {@link runWithTimeout} so the caller can tell a
124
- * lifecycle timeout (the promise is still running detached) apart from an
125
- * ordinary test failure (the promise already settled).
123
+ * Marks the error thrown by the attempt timeout so the runner can distinguish
124
+ * detached lifecycle cleanup from an ordinary test failure.
126
125
  * @typedef {Error & {velociousTestTimeout?: true}} TestTimeoutError
127
126
  */
128
127
  /**
@@ -145,94 +144,6 @@ import {synchronizeTestingPackageTests} from "./testing-package-adapter.js"
145
144
  * @property {import("../database/pool/base.js").TestSharedConnectionRegistration | undefined} sharedRegistration - Physical-key shared registration once published.
146
145
  */
147
146
 
148
- /**
149
- * Runs run with timeout.
150
- *
151
- * On timeout the wrapped `promise` is NOT cancelled — it keeps running detached.
152
- * The rejected error is tagged with `velociousTestTimeout` so the runner knows
153
- * the lifecycle (and its afterEach database cleanup) is still in flight and can
154
- * wait for it to settle before the next test reuses the shared connection.
155
- * @param {Promise<ReturnType<typeof JSON.parse>> | ReturnType<typeof JSON.parse>} promise - Promise or value.
156
- * @param {number} timeoutMs - Timeout in milliseconds.
157
- * @param {string} testDescription - Test description.
158
- * @returns {Promise<ReturnType<typeof JSON.parse>>} - Resolves or rejects based on timeout or promise result.
159
- */
160
- function runWithTimeout(promise, timeoutMs, testDescription) {
161
- const timeoutSeconds = (timeoutMs / 1000).toFixed(3).replace(/\.?0+$/, "")
162
- /** @type {TestTimeoutError} */
163
- const timeoutError = new Error(`Timed out after ${timeoutSeconds}s: ${testDescription}`)
164
- timeoutError.velociousTestTimeout = true
165
-
166
- return new Promise((resolve, reject) => {
167
- const timeout = setTimeout(() => reject(timeoutError), timeoutMs)
168
-
169
- Promise.resolve(promise).then((result) => {
170
- clearTimeout(timeout)
171
- resolve(result)
172
- }).catch((error) => {
173
- clearTimeout(timeout)
174
- reject(error)
175
- })
176
- })
177
- }
178
-
179
- /**
180
- * Waits for an abandoned (timed-out) test lifecycle to settle, bounded by a
181
- * grace period, so its afterEach database cleanup runs on the shared connection
182
- * before the next test reuses it. Returns the fulfillment/rejection outcome if
183
- * the lifecycle settles, or a pending outcome once the grace elapses.
184
- *
185
- * The grace timer is kept ref'd so it cannot let Node exit with an unsettled
186
- * top-level await when the timed-out lifecycle has no ref'd handles of its own
187
- * (for example a stalled mocked async API). Once the caller continues past this
188
- * await, the timer has already resolved and no longer anchors the event loop.
189
- * @param {Promise<ReturnType<typeof JSON.parse>>} lifecycle - The abandoned per-test lifecycle promise.
190
- * @param {number} graceMs - Maximum time to wait for the lifecycle to settle.
191
- * @returns {Promise<{settled: false} | {settled: true, status: "fulfilled"} | {settled: true, status: "rejected", reason: unknown}>} - Settlement outcome.
192
- */
193
- function awaitSettledOrGrace(lifecycle, graceMs) {
194
- return new Promise((resolve) => {
195
- let settled = false
196
- const graceTimer = setTimeout(() => {
197
- if (settled) return
198
-
199
- settled = true
200
- resolve({settled: false})
201
- }, graceMs)
202
-
203
- Promise.resolve(lifecycle).then(
204
- () => {
205
- if (settled) return
206
-
207
- settled = true
208
- clearTimeout(graceTimer)
209
- resolve({settled: true, status: "fulfilled"})
210
- },
211
- (reason) => {
212
- if (settled) return
213
-
214
- settled = true
215
- clearTimeout(graceTimer)
216
- resolve({settled: true, status: "rejected", reason})
217
- }
218
- )
219
- })
220
- }
221
-
222
- /**
223
- * Checks whether a late lifecycle stopped only because its test access was revoked.
224
- * @param {unknown} error - Lifecycle rejection.
225
- * @returns {boolean} - Whether every contained error is expected revocation.
226
- */
227
- function isTestDatabaseAccessRevocation(error) {
228
- if (error instanceof TestDatabaseAccessRevokedError) return true
229
- if (error instanceof AggregateError) {
230
- return error.errors.length > 0 && error.errors.every((nestedError) => isTestDatabaseAccessRevocation(nestedError))
231
- }
232
-
233
- return false
234
- }
235
-
236
147
  /**
237
148
  * Captured console methods.
238
149
  * @type {ConsoleMethodName[]} */
@@ -300,6 +211,10 @@ export default class TestRunner {
300
211
  this._lastTestContext = null
301
212
  /** @type {Array<{fullDescription: string, filePath: string, line: number, durationMs: number}>} */
302
213
  this._testDurations = []
214
+ this._attemptExecutor = new VelociousAttemptExecutor({testRunner: this})
215
+ this._runnerReporter = new VelociousRunnerReporter({testRunner: this})
216
+ this._suiteHookExecutor = new VelociousSuiteHookExecutor({testRunner: this})
217
+ this._testArguments = new VelociousTestArguments({testRunner: this})
303
218
  }
304
219
 
305
220
  /**
@@ -1530,28 +1445,7 @@ export default class TestRunner {
1530
1445
  * @returns {Promise<void>} - Resolves after every hook runs.
1531
1446
  */
1532
1447
  async runAfterEaches({afterEaches, testArgs, testData}) {
1533
- /** @type {unknown[]} */
1534
- const afterEachErrors = []
1535
-
1536
- for (const afterEachData of afterEaches) {
1537
- try {
1538
- await this.runProfileSpan({
1539
- phase: "afterEach",
1540
- declarationIndex: afterEachData.declarationIndex,
1541
- declarationScopeId: afterEachData.declarationScopeId,
1542
- filePath: afterEachData.ownerFilePath
1543
- }, async () => {
1544
- await afterEachData.callback({configuration: this.getConfiguration(), testArgs, testData})
1545
- })
1546
- } catch (error) {
1547
- afterEachErrors.push(error)
1548
- }
1549
- }
1550
-
1551
- if (afterEachErrors.length == 1) throw afterEachErrors[0]
1552
- if (afterEachErrors.length > 1) {
1553
- throw new AggregateError(afterEachErrors, "Multiple afterEach hooks failed", {cause: afterEachErrors[0]})
1554
- }
1448
+ await this._attemptExecutor.runAfterEaches({afterEaches, testArgs, testData})
1555
1449
  }
1556
1450
 
1557
1451
  /**
@@ -1597,32 +1491,17 @@ export default class TestRunner {
1597
1491
  try {
1598
1492
  const beforeAlls = this.profileHookEntries(tests.beforeAlls || [], profileScopeId, scopeOwnerFilePath)
1599
1493
 
1600
- for (const beforeAllData of beforeAlls) {
1601
- await this.runProfileSpan({
1602
- phase: "beforeAll",
1603
- declarationIndex: beforeAllData.declarationIndex,
1604
- declarationScopeId: beforeAllData.declarationScopeId,
1605
- filePath: beforeAllData.ownerFilePath
1606
- }, async () => {
1607
- await beforeAllData.callback({configuration: this.getConfiguration()})
1608
- })
1609
- }
1494
+ await this._suiteHookExecutor.runBeforeAlls({hooks: beforeAlls})
1610
1495
 
1611
1496
  for (const testDescription in tests.tests) {
1612
1497
  const testData = tests.tests[testDescription]
1613
- const testArgs = /** @type {TestArgs} */ (Object.assign({}, testData.args))
1498
+ const testArgs = this._testArguments.copy(testData)
1614
1499
  const includeByLine = scopeLineMatch || this.matchesLineFilter(testData)
1615
1500
 
1616
1501
  if (this._onlyFocussed && !testArgs.focus) continue
1617
1502
  if (this.shouldSkipTest(testArgs, testData, testDescription, descriptions, includeByLine)) continue
1618
1503
 
1619
- if (testArgs.type == "model" || testArgs.type == "request") {
1620
- testArgs.application = await this.application()
1621
- }
1622
-
1623
- if (testArgs.type == "request") {
1624
- testArgs.client = await this.requestClient()
1625
- }
1504
+ await this._testArguments.inject(testArgs)
1626
1505
 
1627
1506
  const retryCount = typeof testArgs.retry === "number" && Number.isFinite(testArgs.retry)
1628
1507
  ? Math.max(0, Math.floor(testArgs.retry))
@@ -1643,435 +1522,42 @@ export default class TestRunner {
1643
1522
  const testStartMs = Date.now()
1644
1523
 
1645
1524
  while (true) {
1646
- let shouldRetry = false
1647
- /**
1648
- * Defines caughtError.
1649
- * @type {ReturnType<typeof JSON.parse>} */
1650
- let caughtError
1651
- /**
1652
- * Defines failedError.
1653
- * @type {ReturnType<typeof JSON.parse>} */
1654
- let failedError
1655
- /**
1656
- * Defines lastError.
1657
- * @type {ReturnType<typeof JSON.parse>} */
1658
- let lastError
1659
- let willRetry = false
1660
- /**
1661
- * The per-test lifecycle promise, hoisted so the timeout branch can
1662
- * still wait for it to settle after runWithTimeout has abandoned it.
1663
- * @type {Promise<ReturnType<typeof JSON.parse>> | undefined} */
1664
- let testLifecycle
1665
- /** @type {{pool: import("../database/pool/base.js").default, registration: import("../database/pool/base.js").TestSharedConnectionRegistration}[]} */
1666
- let testSharedConnectionRegistrations = []
1667
- let testSharedConnectionsActive = false
1668
- /** @type {SharedTransactionBrokerRegistration | undefined} */
1669
- let sharedTransactionBrokerRegistration
1670
- /** @type {SharedTransactionBrokerRegistration | undefined} */
1671
- let sharedTransactionBrokerPreparation
1672
- /** @type {TransactionalTenantRegistration[]} */
1673
- const transactionalTenantRegistrations = []
1674
- /** @type {BrowserDummyConnectionRegistration[]} */
1675
- const browserDummyConnectionRegistrations = []
1676
- const testDatabaseAccessScope = {revoked: false}
1677
- /** @type {Set<Error>} */
1678
- const recordedTimeoutCleanupErrors = new Set()
1679
- testArgs.registerTransactionalTenant = async (args) => {
1680
- await this.registerTransactionalTenant(args, transactionalTenantRegistrations)
1681
- }
1682
- const stopConsoleCapture = this.startConsoleCapture({
1683
- passthrough: testConfig.consoleOutput === "live"
1684
- })
1685
- const profiler = this._profiler
1686
- const profileAttempt = profiler?.startAttempt({
1687
- descriptions,
1525
+ const attemptResult = await this._attemptExecutor.execute({
1526
+ afterEaches: newAfterEaches,
1688
1527
  attemptNumber,
1528
+ beforeEaches: newBeforeEaches,
1529
+ descriptions,
1530
+ testArgs,
1689
1531
  testData,
1690
- testDescription
1532
+ testDescription,
1533
+ timeoutMs
1691
1534
  })
1692
- let attemptTimedOut = false
1693
-
1694
- try {
1695
- // Run the whole per-test lifecycle (dummy/server startup, connection
1696
- // acquisition, beforeEach hooks, the test body and afterEach hooks) as
1697
- // one promise so the timeout below can cover all of it.
1698
- const runLifecycleCallback = async () => await this.runWithDummyIfNeeded(testArgs, async () => {
1699
- const useTransaction = testArgs.databaseCleaning?.transaction === true
1700
- const shouldTruncate = testArgs.databaseCleaning?.truncate ?? !useTransaction
1701
- const useSharedTestConnections = useTransaction || testArgs.type == "request"
1702
- const useTestConnections = useSharedTestConnections || shouldTruncate
1703
- const runTestAttempt = async () => {
1704
- // Register dynamic candidates before hooks so transaction state changes
1705
- // made during a hook are immediately visible to any in-process work.
1706
- // Prepare transaction sharing before hooks so long-lived services cannot
1707
- // use the shared connection while its coordinator is still missing.
1708
- if (useSharedTestConnections) {
1709
- testSharedConnectionRegistrations = this.activateTestSharedConnections()
1710
- testSharedConnectionsActive = true
1711
- }
1712
- /** @type {unknown[]} */
1713
- const lifecycleErrors = []
1714
- let runCleanupHooks = false
1715
-
1716
- try {
1717
- if (useSharedTestConnections) {
1718
- sharedTransactionBrokerPreparation = await this.prepareSharedTransactionBroker()
1719
- }
1720
- runCleanupHooks = true
1721
-
1722
- clearDeliveries()
1723
- for (const beforeEachData of newBeforeEaches) {
1724
- await this.runProfileSpan({
1725
- phase: "beforeEach",
1726
- declarationIndex: beforeEachData.declarationIndex,
1727
- declarationScopeId: beforeEachData.declarationScopeId,
1728
- filePath: beforeEachData.ownerFilePath
1729
- }, async () => {
1730
- await beforeEachData.callback({configuration: this.getConfiguration(), testArgs, testData})
1731
- })
1732
- }
1733
-
1734
- if (useSharedTestConnections) {
1735
- const activeSharedTransactionConnections = this.sharedTransactionConnections({transactionsOnly: true})
1736
- if (sharedTransactionBrokerPreparation && !this.sharedTransactionBrokerMatchesConnections(sharedTransactionBrokerPreparation, activeSharedTransactionConnections)) {
1737
- this.clearTestSharedConnections(testSharedConnectionRegistrations)
1738
- testSharedConnectionRegistrations = []
1739
- testSharedConnectionsActive = false
1740
- }
1741
-
1742
- sharedTransactionBrokerRegistration = await this.startSharedTransactionBroker(sharedTransactionBrokerPreparation, activeSharedTransactionConnections)
1743
- sharedTransactionBrokerPreparation = undefined
1744
- if (sharedTransactionBrokerRegistration && !testSharedConnectionsActive) {
1745
- testSharedConnectionRegistrations = this.activateTestSharedConnections()
1746
- testSharedConnectionsActive = true
1747
- }
1748
- }
1749
-
1750
- // Record which test is running so an async crash (an unhandled
1751
- // rejection detached from any await) that fires during or shortly
1752
- // after this test can be attributed to it in run()'s handler.
1753
- this._lastTestContext = {
1754
- fullDescription: this.buildFullDescription(descriptions, testDescription),
1755
- filePath: testData.filePath ?? "<unknown>",
1756
- line: testData.line ?? 0
1757
- }
1758
- await this.runProfileSpan({phase: "test body", filePath: testData.ownerFilePath ?? testData.filePath}, async () => {
1759
- await testData.function(testArgs)
1760
- })
1761
- } catch (error) {
1762
- lifecycleErrors.push(error)
1763
- }
1764
-
1765
- if (runCleanupHooks) {
1766
- try {
1767
- // Framework-owned post-commit broadcasts are intentionally
1768
- // detached; drain them before test cleanup so their DB
1769
- // checkouts cannot leak into the next test's lifecycle.
1770
- await this.getConfiguration().awaitPendingBroadcasts()
1771
- } catch (error) {
1772
- lifecycleErrors.push(error)
1773
- }
1774
-
1775
- try {
1776
- if (testSharedConnectionsActive) {
1777
- this.clearTestSharedConnections(testSharedConnectionRegistrations)
1778
- testSharedConnectionRegistrations = []
1779
- testSharedConnectionsActive = false
1780
- }
1781
- } catch (error) {
1782
- lifecycleErrors.push(error)
1783
- }
1784
-
1785
- try {
1786
- await this.stopSharedTransactionBroker(sharedTransactionBrokerRegistration || sharedTransactionBrokerPreparation)
1787
- sharedTransactionBrokerRegistration = undefined
1788
- sharedTransactionBrokerPreparation = undefined
1789
- } catch (error) {
1790
- lifecycleErrors.push(error)
1791
- }
1792
-
1793
- try {
1794
- await this.runAfterEaches({afterEaches: newAfterEaches, testArgs, testData})
1795
- } catch (error) {
1796
- lifecycleErrors.push(error)
1797
- }
1798
-
1799
- try {
1800
- await this.cleanupTransactionalTenants(transactionalTenantRegistrations)
1801
- } catch (error) {
1802
- lifecycleErrors.push(error)
1803
- }
1804
- }
1805
-
1806
- if (testSharedConnectionsActive) {
1807
- try {
1808
- this.clearTestSharedConnections(testSharedConnectionRegistrations)
1809
- } catch (error) {
1810
- lifecycleErrors.push(error)
1811
- }
1812
- testSharedConnectionsActive = false
1813
- }
1814
-
1815
- if (lifecycleErrors.length == 1) throw lifecycleErrors[0]
1816
- if (lifecycleErrors.length > 1) {
1817
- throw new AggregateError(lifecycleErrors, "Test lifecycle and cleanup failed", {cause: lifecycleErrors[0]})
1818
- }
1819
- }
1820
-
1821
- if (useTestConnections) {
1822
- // Database cleaning requires one connection for beforeEach, the test
1823
- // body and afterEach; only transactions and requests share it dynamically.
1824
- await this.getConfiguration().ensureConnections({name: `Test: ${testDescription}`}, runTestAttempt)
1825
- } else {
1826
- await runTestAttempt()
1827
- }
1828
- }, browserDummyConnectionRegistrations)
1829
- const lifecycleCallback = async () => await this.getConfiguration().runWithTestDatabaseAccessScope(testDatabaseAccessScope, runLifecycleCallback)
1830
- testLifecycle = profileAttempt && profiler
1831
- ? profiler.runAttempt(profileAttempt, lifecycleCallback)
1832
- : lifecycleCallback()
1833
-
1834
- // Time out the ENTIRE lifecycle, not just the test body. A hang in any
1835
- // phase — a connection checkout that never resolves, a beforeEach/afterEach
1836
- // waiting on a lock, or dummy server startup — would otherwise stall the
1837
- // whole run indefinitely (until CI kills the build) instead of failing the
1838
- // single offending test.
1839
- if (useTimeout && timeoutMs !== undefined) {
1840
- await runWithTimeout(testLifecycle, timeoutMs, testDescription)
1841
- } else {
1842
- await testLifecycle
1843
- }
1844
-
1845
- // A test is successful only after its complete lifecycle settles.
1846
- // Cleanup failures and timed-out detached work must not overlap the
1847
- // final successful and failed counters used for executed-test totals.
1848
- this._successfulTests++
1849
- } catch (error) {
1850
- caughtError = error
1851
- lastError = error
1852
-
1853
- // A timeout REJECTS while the lifecycle keeps running detached on the
1854
- // shared per-suite connection — including its afterEach database
1855
- // cleanup (e.g. transaction rollback). If the next test starts before
1856
- // that rollback runs, its own startTransaction() implicitly COMMITS
1857
- // the timed-out test's rows on the shared connection, poisoning every
1858
- // later test in the shard (duplicate-key / foreign-key cascades from
1859
- // leaked fixtures). Wait — bounded — for the abandoned lifecycle to
1860
- // settle so its cleanup lands first. If it remains active after the
1861
- // bounded grace, quarantine its browser connections and stop running
1862
- // tests rather than sharing unsafe state.
1863
- const timedOut = Boolean(/** @type {TestTimeoutError} */ (error)?.velociousTestTimeout)
1864
- attemptTimedOut = timedOut
1865
-
1866
- if (timedOut && testLifecycle) {
1867
- const emergencyCleanupErrors = []
1868
-
1869
- if (profileAttempt && profiler) profiler.finishAttempt(profileAttempt, "timed-out")
1870
- const lifecycleOutcome = await awaitSettledOrGrace(testLifecycle, timeoutMs ?? 60000)
1871
-
1872
- if (lifecycleOutcome.settled && lifecycleOutcome.status === "rejected") {
1873
- emergencyCleanupErrors.push(lifecycleOutcome.reason)
1874
- }
1875
-
1876
- // If the abandoned lifecycle never settled within the grace, its
1877
- // cleanup has not completed. Quarantine browser-owned connections
1878
- // before any scope cleanup can race the abandoned callback.
1879
- if (!lifecycleOutcome.settled) {
1880
- testDatabaseAccessScope.revoked = true
1881
- void testLifecycle.catch((cleanupError) => {
1882
- if (isTestDatabaseAccessRevocation(cleanupError)) return
1883
- this.recordTimeoutCleanupFailure(cleanupError, "test lifecycle", recordedTimeoutCleanupErrors)
1884
- })
1885
- const quarantine = this.quarantineBrowserDummyConnections(browserDummyConnectionRegistrations)
1886
- const quarantineOutcome = await awaitSettledOrGrace(quarantine, timeoutMs ?? 60000)
1887
- const usesBrowserTransactions = testArgs.databaseCleaning?.transaction === true
1888
- const usesBrowserTruncation = testArgs.databaseCleaning?.truncate ?? !usesBrowserTransactions
1889
-
1890
- this._abortRemainingTests = this.isBrowserTestMode()
1891
- && this.hasTag(testArgs, "dummy")
1892
- && (usesBrowserTransactions || usesBrowserTruncation)
1893
-
1894
- if (quarantineOutcome.settled && quarantineOutcome.status === "rejected") {
1895
- emergencyCleanupErrors.push(quarantineOutcome.reason)
1896
- } else if (!quarantineOutcome.settled) {
1897
- void quarantine.catch((cleanupError) => {
1898
- this.recordTimeoutCleanupFailure(cleanupError, "browser dummy connection quarantine", recordedTimeoutCleanupErrors)
1899
- })
1900
- }
1901
- }
1902
-
1903
- try {
1904
- if (testSharedConnectionsActive) {
1905
- this.clearTestSharedConnections(testSharedConnectionRegistrations)
1906
- testSharedConnectionRegistrations = []
1907
- testSharedConnectionsActive = false
1908
- }
1909
- } catch (cleanupError) {
1910
- emergencyCleanupErrors.push(cleanupError)
1911
- }
1912
-
1913
- const brokerCleanup = this.stopSharedTransactionBroker(sharedTransactionBrokerRegistration || sharedTransactionBrokerPreparation)
1914
- const brokerCleanupOutcome = await awaitSettledOrGrace(brokerCleanup, timeoutMs ?? 60000)
1915
-
1916
- if (brokerCleanupOutcome.settled && brokerCleanupOutcome.status === "rejected") {
1917
- emergencyCleanupErrors.push(brokerCleanupOutcome.reason)
1918
- } else if (!brokerCleanupOutcome.settled) {
1919
- void brokerCleanup.catch((cleanupError) => {
1920
- this.recordTimeoutCleanupFailure(cleanupError, "shared transaction broker", recordedTimeoutCleanupErrors)
1921
- })
1922
- }
1923
- sharedTransactionBrokerRegistration = undefined
1924
- sharedTransactionBrokerPreparation = undefined
1925
- const emergencyCleanup = this.cleanupTransactionalTenants(transactionalTenantRegistrations, {discard: true})
1926
- const emergencyCleanupOutcome = await awaitSettledOrGrace(emergencyCleanup, timeoutMs ?? 60000)
1927
-
1928
- if (emergencyCleanupOutcome.settled && emergencyCleanupOutcome.status === "rejected") {
1929
- emergencyCleanupErrors.push(emergencyCleanupOutcome.reason)
1930
- } else if (!emergencyCleanupOutcome.settled) {
1931
- // The timed-out attempt must not block the runner indefinitely, but a
1932
- // later rollback/discard failure still becomes a visible test failure.
1933
- void emergencyCleanup.catch((cleanupError) => {
1934
- this.recordTimeoutCleanupFailure(cleanupError, "transactional tenant", recordedTimeoutCleanupErrors)
1935
- })
1936
- }
1937
-
1938
- if (emergencyCleanupErrors.length > 0) {
1939
- caughtError = new AggregateError(
1940
- [caughtError, ...emergencyCleanupErrors],
1941
- "Test timeout and emergency cleanup failed",
1942
- {cause: caughtError}
1943
- )
1944
- lastError = caughtError
1945
- }
1946
- }
1947
-
1948
- if (browserDummyConnectionRegistrations.some((registration) => registration.quarantined)) {
1949
- testDatabaseAccessScope.revoked = true
1950
- this._abortRemainingTests = true
1951
- }
1952
-
1953
- willRetry = !this._abortRemainingTests && retriesUsed < retryCount
1954
-
1955
- if (willRetry) {
1956
- retriesUsed++
1957
- }
1958
-
1959
- if (willRetry) {
1960
- shouldRetry = true
1961
- } else {
1962
- failedError = caughtError
1963
- }
1964
- } finally {
1965
- testDatabaseAccessScope.revoked = true
1966
- const consoleOutput = stopConsoleCapture()
1967
-
1968
- if (profileAttempt && profiler) {
1969
- profiler.finishAttempt(profileAttempt, caughtError === undefined
1970
- ? "passed"
1971
- : (attemptTimedOut ? "timed-out" : "failed"))
1972
- }
1973
-
1974
- if (consoleOutput) {
1975
- attemptConsoleOutputs.push({attemptNumber, output: consoleOutput})
1976
- }
1977
- }
1978
1535
 
1979
- if (caughtError !== undefined) {
1980
- await this.emitEvent("testAttemptFailed", {
1981
- configuration: this.getConfiguration(),
1982
- descriptions,
1983
- error: caughtError,
1984
- attemptNumber,
1985
- nextAttempt: willRetry ? attemptNumber + 1 : undefined,
1986
- retriesUsed,
1987
- retryCount,
1988
- testArgs,
1989
- testData,
1990
- testDescription,
1991
- testRunner: this,
1992
- willRetry
1993
- })
1536
+ if (attemptResult.consoleOutput) {
1537
+ attemptConsoleOutputs.push({attemptNumber, output: attemptResult.consoleOutput})
1994
1538
  }
1539
+ if (attemptResult.abortRemainingTests) this._abortRemainingTests = true
1995
1540
 
1996
- if (shouldRetry) {
1997
- console.warn(picocolors.red(`${leftPadding} Retrying (${retriesUsed}/${retryCount}) after error: ${lastError instanceof Error ? lastError.message : String(lastError)}`))
1998
- await this.emitEvent("testRetrying", {
1999
- configuration: this.getConfiguration(),
2000
- descriptions,
2001
- error: lastError,
2002
- nextAttempt: attemptNumber + 1,
2003
- retriesUsed,
2004
- retryCount,
2005
- testArgs,
2006
- testData,
2007
- testDescription,
2008
- testRunner: this
2009
- })
2010
- }
2011
-
2012
- if (attemptNumber > 1) {
2013
- await this.emitEvent("testRetried", {
2014
- configuration: this.getConfiguration(),
2015
- descriptions,
2016
- error: lastError,
2017
- attemptNumber,
2018
- retriesUsed,
2019
- retryCount,
2020
- testArgs,
2021
- testData,
2022
- testDescription,
2023
- testRunner: this
2024
- })
2025
- }
1541
+ const willRetry = attemptResult.failed && !this._abortRemainingTests && retriesUsed < retryCount
1542
+ if (willRetry) retriesUsed++
2026
1543
 
1544
+ await this._runnerReporter.reportAttempt({
1545
+ attemptConsoleOutputs,
1546
+ attemptNumber,
1547
+ descriptions,
1548
+ error: attemptResult.error,
1549
+ failed: attemptResult.failed,
1550
+ leftPadding,
1551
+ retriesUsed,
1552
+ retryCount,
1553
+ testArgs,
1554
+ testData,
1555
+ testDescription,
1556
+ willRetry
1557
+ })
2027
1558
  attemptNumber++
2028
1559
 
2029
- if (shouldRetry) continue
2030
-
2031
- if (failedError) {
2032
- const consoleOutput = this.buildConsoleOutput(attemptConsoleOutputs)
2033
-
2034
- if (failedError instanceof Error) {
2035
- console.error(picocolors.red(`${leftPadding} Test failed: ${failedError.message}`))
2036
- addTrackedStackToError(failedError)
2037
-
2038
- const backtraceCleaner = new BacktraceCleaner(failedError)
2039
- const cleanedStack = backtraceCleaner.getCleanedStack()
2040
- const stackLines = cleanedStack?.split("\n")
2041
-
2042
- if (stackLines) {
2043
- for (const stackLine of stackLines) {
2044
- console.error(picocolors.red(`${leftPadding} ${stackLine}`))
2045
- }
2046
- }
2047
- } else {
2048
- console.error(picocolors.red(`${leftPadding} Test failed with a ${typeof failedError}: ${String(failedError)}`))
2049
- }
2050
-
2051
- this.printFailedConsoleOutput({consoleOutput, leftPadding})
2052
- this._failedTests++
2053
- this._failedTestDetails.push({
2054
- fullDescription: this.buildFullDescription(descriptions, testDescription),
2055
- filePath: testData.filePath,
2056
- line: testData.line,
2057
- error: failedError,
2058
- consoleOutput: consoleOutput || undefined
2059
- })
2060
-
2061
- await this.emitEvent("testFailed", {
2062
- configuration: this.getConfiguration(),
2063
- descriptions,
2064
- error: failedError,
2065
- testArgs,
2066
- testData,
2067
- testDescription,
2068
- testRunner: this
2069
- })
2070
-
2071
- this.printRerunCommand({descriptions, testDescription, testData, leftPadding})
2072
- }
2073
-
2074
- break
1560
+ if (!willRetry) break
2075
1561
  }
2076
1562
 
2077
1563
  this._testDurations.push({
@@ -2142,33 +1628,13 @@ export default class TestRunner {
2142
1628
  scopeEntry.afterAllsRun = true
2143
1629
 
2144
1630
  const scopeOwnerFilePath = scopeEntry.tests.ownerFilePath ?? scopeEntry.tests.filePath
2145
- const afterAlls = [...this.profileHookEntries(
1631
+ const afterAlls = this.profileHookEntries(
2146
1632
  scopeEntry.tests.afterAlls || [],
2147
1633
  scopeEntry.profileScopeId,
2148
1634
  scopeOwnerFilePath
2149
- )].reverse()
2150
- /** @type {unknown[]} */
2151
- const afterAllErrors = []
2152
-
2153
- for (const afterAllData of afterAlls) {
2154
- try {
2155
- await this.runProfileSpan({
2156
- phase: "afterAll",
2157
- declarationIndex: afterAllData.declarationIndex,
2158
- declarationScopeId: afterAllData.declarationScopeId,
2159
- filePath: afterAllData.ownerFilePath
2160
- }, async () => {
2161
- await afterAllData.callback({configuration: this.getConfiguration()})
2162
- })
2163
- } catch (error) {
2164
- afterAllErrors.push(error)
2165
- }
2166
- }
1635
+ )
2167
1636
 
2168
- if (afterAllErrors.length == 1) throw afterAllErrors[0]
2169
- if (afterAllErrors.length > 1) {
2170
- throw new AggregateError(afterAllErrors, "Multiple afterAll hooks failed", {cause: afterAllErrors[0]})
2171
- }
1637
+ await this._suiteHookExecutor.runAfterAlls({hooks: afterAlls})
2172
1638
  }
2173
1639
 
2174
1640
  /**
@@ -2178,11 +1644,7 @@ export default class TestRunner {
2178
1644
  * @returns {Promise<void>} - Resolves when all listeners complete.
2179
1645
  */
2180
1646
  async emitEvent(eventName, payload) {
2181
- const listeners = testEvents.listeners(eventName)
2182
-
2183
- for (const listener of listeners) {
2184
- await listener(payload)
2185
- }
1647
+ await this._runnerReporter.emitEvent(eventName, payload)
2186
1648
  }
2187
1649
 
2188
1650
  /**