velocious 1.0.603 → 1.0.604

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 (72) hide show
  1. package/README.md +23 -1
  2. package/build/configuration.js +19 -0
  3. package/build/database/drivers/base.js +105 -7
  4. package/build/database/drivers/mysql/index.js +21 -6
  5. package/build/database/pool/async-tracked-multi-connection.js +136 -28
  6. package/build/database/pool/base.js +1 -1
  7. package/build/environment-handlers/base.js +16 -0
  8. package/build/environment-handlers/node/cli/commands/test.js +220 -84
  9. package/build/environment-handlers/node.js +40 -1
  10. package/build/src/configuration.d.ts +9 -0
  11. package/build/src/configuration.d.ts.map +1 -1
  12. package/build/src/configuration.js +17 -1
  13. package/build/src/database/drivers/base.d.ts +40 -1
  14. package/build/src/database/drivers/base.d.ts.map +1 -1
  15. package/build/src/database/drivers/base.js +97 -8
  16. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  17. package/build/src/database/drivers/mysql/index.js +25 -9
  18. package/build/src/database/pool/async-tracked-multi-connection.d.ts +42 -2
  19. package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
  20. package/build/src/database/pool/async-tracked-multi-connection.js +119 -30
  21. package/build/src/database/pool/base.d.ts +11 -1
  22. package/build/src/database/pool/base.d.ts.map +1 -1
  23. package/build/src/database/pool/base.js +2 -2
  24. package/build/src/environment-handlers/base.d.ts +14 -0
  25. package/build/src/environment-handlers/base.d.ts.map +1 -1
  26. package/build/src/environment-handlers/base.js +15 -1
  27. package/build/src/environment-handlers/node/cli/commands/test.d.ts +22 -0
  28. package/build/src/environment-handlers/node/cli/commands/test.d.ts.map +1 -1
  29. package/build/src/environment-handlers/node/cli/commands/test.js +200 -82
  30. package/build/src/environment-handlers/node.d.ts +14 -0
  31. package/build/src/environment-handlers/node.d.ts.map +1 -1
  32. package/build/src/environment-handlers/node.js +38 -2
  33. package/build/src/testing/test-filter-parser.d.ts +15 -0
  34. package/build/src/testing/test-filter-parser.d.ts.map +1 -1
  35. package/build/src/testing/test-filter-parser.js +57 -6
  36. package/build/src/testing/test-profile-activity.d.ts +7 -0
  37. package/build/src/testing/test-profile-activity.d.ts.map +1 -0
  38. package/build/src/testing/test-profile-activity.js +13 -0
  39. package/build/src/testing/test-profile-context.d.ts +15 -0
  40. package/build/src/testing/test-profile-context.d.ts.map +1 -0
  41. package/build/src/testing/test-profile-context.js +25 -0
  42. package/build/src/testing/test-profile-output.d.ts +30 -0
  43. package/build/src/testing/test-profile-output.d.ts.map +1 -0
  44. package/build/src/testing/test-profile-output.js +204 -0
  45. package/build/src/testing/test-profiler.d.ts +668 -0
  46. package/build/src/testing/test-profiler.d.ts.map +1 -0
  47. package/build/src/testing/test-profiler.js +847 -0
  48. package/build/src/testing/test-runner.d.ts +85 -2
  49. package/build/src/testing/test-runner.d.ts.map +1 -1
  50. package/build/src/testing/test-runner.js +182 -33
  51. package/build/testing/test-filter-parser.js +57 -5
  52. package/build/testing/test-profile-activity.js +14 -0
  53. package/build/testing/test-profile-context.js +28 -0
  54. package/build/testing/test-profile-output.js +240 -0
  55. package/build/testing/test-profiler.js +917 -0
  56. package/build/testing/test-runner.js +196 -33
  57. package/build/tsconfig.tsbuildinfo +1 -1
  58. package/package.json +1 -1
  59. package/src/configuration.js +19 -0
  60. package/src/database/drivers/base.js +105 -7
  61. package/src/database/drivers/mysql/index.js +21 -6
  62. package/src/database/pool/async-tracked-multi-connection.js +136 -28
  63. package/src/database/pool/base.js +1 -1
  64. package/src/environment-handlers/base.js +16 -0
  65. package/src/environment-handlers/node/cli/commands/test.js +220 -84
  66. package/src/environment-handlers/node.js +40 -1
  67. package/src/testing/test-filter-parser.js +57 -5
  68. package/src/testing/test-profile-activity.js +14 -0
  69. package/src/testing/test-profile-context.js +28 -0
  70. package/src/testing/test-profile-output.js +240 -0
  71. package/src/testing/test-profiler.js +917 -0
  72. package/src/testing/test-runner.js +196 -33
@@ -0,0 +1,917 @@
1
+ // @ts-check
2
+
3
+ import { createHash } from "node:crypto"
4
+ import path from "node:path"
5
+ import { registerTestProfileContextReader } from "./test-profile-context.js"
6
+ import { validateTestActivityName } from "./test-profile-activity.js"
7
+
8
+ /** @typedef {"passed" | "failed" | "interrupted" | "timed-out"} TestProfileAttemptStatus */
9
+ /** @typedef {{user: number, system: number}} ProcessCpuUsage */
10
+ /** @typedef {{queryCount: number, failedQueryCount: number, totalMs: number, maxMs: number}} ProfileDatabaseAggregate */
11
+ /** @typedef {{count: number, failedCount: number, totalMs: number, maxMs: number}} ProfileActionAggregate */
12
+ /** @typedef {{identifier: string, connectionCreation: ProfileActionAggregate, checkoutWait: {count: number, totalMs: number, maxMs: number}, checkoutTimeoutCount: number, idleReap: ProfileActionAggregate & {disposalCount: number}, peakLiveConnections: number}} ProfilePoolAggregate */
13
+
14
+ /**
15
+ * @typedef {object} TestProfileAsyncContext
16
+ * @property {TestProfiler} profiler - Owning profiler.
17
+ * @property {TestProfileAttemptRecord | undefined} attempt - Active test attempt.
18
+ * @property {boolean} active - Whether attribution is still open.
19
+ * @property {Set<TestProfileAsyncContext> | undefined} [attemptContexts] - Contexts sharing one attempt lifetime.
20
+ * @property {string | undefined} filePath - Project-relative owning test file.
21
+ * @property {TestProfileSpan | undefined} [span] - Innermost active profile span.
22
+ * @property {number} [spanStartedAt] - Span monotonic start time.
23
+ * @property {ProcessCpuUsage} [spanCpuStartedAt] - Span CPU start time.
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} TestProfileSpan
28
+ * @property {string} phase - Profile phase.
29
+ * @property {number} executionOrder - Monotonic span start order.
30
+ * @property {number} durationMs - Real duration.
31
+ * @property {{user: number, system: number, total: number}} cpuMs - Process CPU duration.
32
+ * @property {string} [activity] - Validated custom activity name.
33
+ * @property {number} [declarationIndex] - Hook index within its declaration scope.
34
+ * @property {string} [declarationScopeId] - Opaque declaration scope identifier.
35
+ * @property {string} [file] - Project-relative source path.
36
+ * @property {ProfileDatabaseAggregate} [database] - Span database aggregate.
37
+ * @property {ProfilePoolAggregate[]} [pools] - Span pool aggregates.
38
+ */
39
+
40
+ /**
41
+ * @typedef {object} TestProfileAttemptRecord
42
+ * @property {number} number - One-indexed attempt number.
43
+ * @property {TestProfileAttemptStatus | "running"} status - Attempt result.
44
+ * @property {number} durationMs - Real duration.
45
+ * @property {{user: number, system: number, total: number}} cpuMs - Process CPU duration.
46
+ * @property {TestProfileSpan[]} spans - Attempt-owned spans.
47
+ */
48
+
49
+ /**
50
+ * @typedef {object} TestProfileAttemptHandle
51
+ * @property {TestProfileAsyncContext} context - Async attribution context.
52
+ * @property {TestProfileAttemptRecord} attempt - Output attempt record.
53
+ * @property {number} startedAt - Real start time.
54
+ * @property {ProcessCpuUsage} cpuStartedAt - CPU start time.
55
+ */
56
+
57
+ /**
58
+ * @typedef {object} InternalPhaseAggregate
59
+ * @property {number} count - Invocation count.
60
+ * @property {number} totalMs - Total real duration.
61
+ * @property {number} maxMs - Maximum real duration.
62
+ * @property {number} cpuUserMs - Total user CPU time.
63
+ * @property {number} cpuSystemMs - Total system CPU time.
64
+ */
65
+
66
+ /**
67
+ * @typedef {object} InternalFileAggregate
68
+ * @property {string} path - Project-relative path.
69
+ * @property {number} importMs - Import duration.
70
+ * @property {number} hooksMs - Hook duration.
71
+ * @property {number} testsMs - Test body duration.
72
+ * @property {number} attemptsMs - Complete test-attempt duration.
73
+ * @property {number} totalMs - Total file weight duration.
74
+ */
75
+
76
+ /**
77
+ * @typedef {object} InternalTestRecord
78
+ * @property {string} id - Opaque stable test identifier.
79
+ * @property {string} file - Project-relative source path.
80
+ * @property {number | undefined} line - Declaration line.
81
+ * @property {TestProfileAttemptRecord[]} attempts - Attempt records.
82
+ */
83
+
84
+ const BUILT_IN_PHASES = [
85
+ "discovery",
86
+ "imports",
87
+ "testing config/global setup",
88
+ "beforeAll",
89
+ "beforeEach",
90
+ "test body",
91
+ "afterEach",
92
+ "afterAll"
93
+ ]
94
+ const MAX_CUSTOM_ACTIVITY_NAMES = 20
95
+ const SAFE_SQL_OPERATIONS = new Set([
96
+ "ALTER",
97
+ "ANALYZE",
98
+ "BEGIN",
99
+ "CALL",
100
+ "COMMIT",
101
+ "COPY",
102
+ "CREATE",
103
+ "DELETE",
104
+ "DESCRIBE",
105
+ "DROP",
106
+ "EXEC",
107
+ "EXECUTE",
108
+ "EXPLAIN",
109
+ "GRANT",
110
+ "INSERT",
111
+ "MERGE",
112
+ "PRAGMA",
113
+ "RELEASE",
114
+ "REPLACE",
115
+ "REVOKE",
116
+ "ROLLBACK",
117
+ "SAVEPOINT",
118
+ "SELECT",
119
+ "SET",
120
+ "SHOW",
121
+ "TRUNCATE",
122
+ "UPDATE",
123
+ "VACUUM",
124
+ "VALUES",
125
+ "WITH"
126
+ ])
127
+
128
+ /**
129
+ * Rounds and bounds duration values for public output.
130
+ * @param {number} durationMs - Raw duration.
131
+ * @returns {number} - Safe duration.
132
+ */
133
+ export function roundProfileDuration(durationMs) {
134
+ if (!Number.isFinite(durationMs) || durationMs < 0) return 0
135
+
136
+ return Math.round(durationMs * 1000) / 1000
137
+ }
138
+
139
+ /**
140
+ * Collects opt-in test-run timing without retaining application payloads.
141
+ */
142
+ export default class TestProfiler {
143
+ /**
144
+ * Creates an opt-in test profile collector.
145
+ * @param {object} args - Profiler options.
146
+ * @param {import("../configuration.js").default} args.configuration - Test configuration.
147
+ * @param {string} args.projectDirectory - Project root used for portable paths.
148
+ * @param {Record<string, ReturnType<typeof JSON.parse>>} [args.selection] - Sanitized selection metadata.
149
+ */
150
+ constructor({configuration, projectDirectory, selection = {}}) {
151
+ this._configuration = configuration
152
+ this._projectDirectory = path.resolve(projectDirectory)
153
+ this._selection = selection
154
+ this._startedAt = this.now()
155
+ this._cpuStartedAt = process.cpuUsage()
156
+ this._executionOrder = 0
157
+ this._unattributedLateEventCount = 0
158
+ /** @type {Map<string, InternalPhaseAggregate>} */
159
+ this._phases = new Map()
160
+ /** @type {Map<string, InternalFileAggregate>} */
161
+ this._files = new Map()
162
+ /** @type {InternalTestRecord[]} */
163
+ this._tests = []
164
+ /** @type {Map<string, InternalTestRecord>} */
165
+ this._testsById = new Map()
166
+ /** @type {Set<TestProfileAttemptHandle>} */
167
+ this._activeAttempts = new Set()
168
+ /** @type {Set<TestProfileAsyncContext>} */
169
+ this._activeSpanContexts = new Set()
170
+ /** @type {TestProfileSpan[]} */
171
+ this._spans = []
172
+ /** @type {Array<{id: string, parentId: string | undefined, file: string, line: number | undefined}>} */
173
+ this._scopes = []
174
+ /** @type {WeakMap<import("./test-runner.js").TestsArgument, string>} */
175
+ this._scopeIds = new WeakMap()
176
+ /** @type {Set<string>} */
177
+ this._customActivityNames = new Set()
178
+ this._database = this.emptyDatabaseAggregate()
179
+ /** @type {Map<string, {hash: string, operation: string, count: number, failedCount: number, totalMs: number, maxMs: number}>} */
180
+ this._queryFingerprints = new Map()
181
+ this._transactions = {
182
+ start: this.phaseAggregateShape(),
183
+ commit: this.phaseAggregateShape(),
184
+ rollback: this.phaseAggregateShape()
185
+ }
186
+ /** @type {Map<string, ProfilePoolAggregate>} */
187
+ this._pools = new Map()
188
+ this._finishedProfile = undefined
189
+
190
+ registerTestProfileContextReader(configuration, () => {
191
+ return configuration.getEnvironmentHandler().getCurrentTestProfileContext()
192
+ })
193
+
194
+ for (const phase of BUILT_IN_PHASES) this.phaseAggregate(phase)
195
+ }
196
+
197
+ /**
198
+ * Returns the profiler's monotonic clock.
199
+ * @returns {number} - Monotonic milliseconds.
200
+ */
201
+ now() {
202
+ return globalThis.performance.now()
203
+ }
204
+
205
+ /**
206
+ * Converts a source path to a project-relative path or an opaque hash.
207
+ * @param {string | undefined} filePath - Source path.
208
+ * @returns {string} - Safe source identifier.
209
+ */
210
+ safeSourcePath(filePath) {
211
+ if (!filePath) return "sha256:unknown"
212
+
213
+ const absolutePath = path.resolve(filePath)
214
+ const relativePath = path.relative(this._projectDirectory, absolutePath)
215
+
216
+ if (relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath)) {
217
+ return relativePath.replaceAll(path.sep, "/")
218
+ }
219
+
220
+ if (!relativePath) return path.basename(absolutePath)
221
+
222
+ return this.hash(`external-source:${absolutePath}`)
223
+ }
224
+
225
+ /**
226
+ * Returns a bounded opaque SHA-256 identifier.
227
+ * @param {string} value - Value to hash.
228
+ * @returns {string} - Opaque hash.
229
+ */
230
+ hash(value) {
231
+ return `sha256:${createHash("sha256").update(value).digest("hex").slice(0, 24)}`
232
+ }
233
+
234
+ /**
235
+ * Adds aggregate-only selection metadata as discovery progresses.
236
+ * @param {Record<string, ReturnType<typeof JSON.parse>>} selection - Safe selection metadata.
237
+ * @returns {void}
238
+ */
239
+ setSelection(selection) {
240
+ Object.assign(this._selection, selection)
241
+ }
242
+
243
+ /**
244
+ * Registers and returns a deterministic scope identifier.
245
+ * @param {import("./test-runner.js").TestsArgument} scope - Scope object.
246
+ * @param {object} args - Scope metadata.
247
+ * @param {string[]} args.descriptions - Scope description path.
248
+ * @param {string | undefined} args.filePath - Scope source path.
249
+ * @param {number | undefined} [args.line] - Scope source line.
250
+ * @param {string | undefined} [args.parentId] - Parent scope identifier.
251
+ * @returns {string} - Opaque scope identifier.
252
+ */
253
+ scopeId(scope, {descriptions, filePath, line, parentId}) {
254
+ const existing = this._scopeIds.get(scope)
255
+ if (existing) return existing
256
+
257
+ const safeFilePath = this.safeSourcePath(filePath)
258
+ const id = this.hash(`scope:${safeFilePath}:${line ?? 0}:${descriptions.join("\u0000")}`)
259
+
260
+ this._scopeIds.set(scope, id)
261
+ this._scopes.push({id, parentId, file: safeFilePath, line})
262
+ return id
263
+ }
264
+
265
+ /**
266
+ * Starts an attempt and its async attribution context.
267
+ * @param {object} args - Attempt metadata.
268
+ * @param {string[]} args.descriptions - Parent descriptions.
269
+ * @param {number} args.attemptNumber - Attempt number.
270
+ * @param {import("./test-runner.js").TestData} args.testData - Test declaration.
271
+ * @param {string} args.testDescription - Test description.
272
+ * @returns {TestProfileAttemptHandle} - Active attempt handle.
273
+ */
274
+ startAttempt({descriptions, attemptNumber, testData, testDescription}) {
275
+ const file = this.safeSourcePath(testData.ownerFilePath ?? testData.filePath)
276
+ const id = this.hash(`test:${file}:${testData.line ?? 0}:${[...descriptions, testDescription].join("\u0000")}`)
277
+ let testRecord = this._testsById.get(id)
278
+
279
+ if (!testRecord) {
280
+ testRecord = {id, file, line: testData.line, attempts: []}
281
+ this._testsById.set(id, testRecord)
282
+ this._tests.push(testRecord)
283
+ }
284
+
285
+ /** @type {TestProfileAttemptRecord} */
286
+ const attempt = {
287
+ number: attemptNumber,
288
+ status: "running",
289
+ durationMs: 0,
290
+ cpuMs: {user: 0, system: 0, total: 0},
291
+ spans: []
292
+ }
293
+ /** @type {Set<TestProfileAsyncContext>} */
294
+ const attemptContexts = new Set()
295
+ const context = {profiler: this, attempt, active: true, attemptContexts, filePath: file, span: undefined}
296
+
297
+ attemptContexts.add(context)
298
+
299
+ const handle = {context, attempt, startedAt: this.now(), cpuStartedAt: process.cpuUsage()}
300
+
301
+ testRecord.attempts.push(attempt)
302
+ this._activeAttempts.add(handle)
303
+
304
+ return handle
305
+ }
306
+
307
+ /**
308
+ * Runs work inside an attempt's async context.
309
+ * @template T
310
+ * @param {TestProfileAttemptHandle} handle - Attempt handle.
311
+ * @param {() => Promise<T>} callback - Attempt lifecycle.
312
+ * @returns {Promise<T>} - Callback result.
313
+ */
314
+ async runAttempt(handle, callback) {
315
+ return await this._configuration
316
+ .getEnvironmentHandler()
317
+ .runWithTestProfileContext(handle.context, callback)
318
+ }
319
+
320
+ /**
321
+ * Completes an attempt and prevents descendant late work from retaining attribution.
322
+ * @param {TestProfileAttemptHandle} handle - Attempt handle.
323
+ * @param {TestProfileAttemptStatus} status - Attempt result.
324
+ * @returns {void}
325
+ */
326
+ finishAttempt(handle, status) {
327
+ if (handle.attempt.status !== "running") return
328
+
329
+ for (const context of handle.context.attemptContexts || []) {
330
+ if (context.span) this.finishSpanContext(context)
331
+ context.active = false
332
+ }
333
+
334
+ handle.attempt.status = status
335
+ handle.attempt.durationMs = roundProfileDuration(this.now() - handle.startedAt)
336
+ handle.attempt.cpuMs = this.cpuDuration(handle.cpuStartedAt)
337
+ this.addFileAttemptDuration(handle.context.filePath, handle.attempt.durationMs)
338
+ this._activeAttempts.delete(handle)
339
+ }
340
+
341
+ /**
342
+ * Closes every active attempt and nested span at the current interruption boundary.
343
+ * @returns {void}
344
+ */
345
+ interrupt() {
346
+ for (const handle of [...this._activeAttempts]) {
347
+ this.finishAttempt(handle, "interrupted")
348
+ }
349
+
350
+ for (const context of [...this._activeSpanContexts]) {
351
+ this.finishSpanContext(context)
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Runs a runner-owned phase span.
357
+ * @template T
358
+ * @param {object} args - Span metadata.
359
+ * @param {string} args.phase - Phase name.
360
+ * @param {string} [args.activity] - Custom activity label.
361
+ * @param {number} [args.declarationIndex] - Hook declaration index.
362
+ * @param {string} [args.declarationScopeId] - Hook declaration scope.
363
+ * @param {string} [args.filePath] - Source or owning test file.
364
+ * @param {() => (T | Promise<T>)} callback - Timed work.
365
+ * @returns {Promise<T>} - Callback result.
366
+ */
367
+ async runSpan({phase, activity, declarationIndex, declarationScopeId, filePath}, callback) {
368
+ const currentContext = this._configuration.getEnvironmentHandler().getCurrentTestProfileContext()
369
+
370
+ if (currentContext && !this.contextIsActive(currentContext)) {
371
+ this._unattributedLateEventCount++
372
+ return await callback()
373
+ }
374
+
375
+ const safeFilePath = currentContext?.filePath ?? (filePath ? this.safeSourcePath(filePath) : undefined)
376
+ const startedAt = this.now()
377
+ const cpuStartedAt = process.cpuUsage()
378
+ /** @type {TestProfileSpan} */
379
+ const span = {
380
+ phase,
381
+ executionOrder: ++this._executionOrder,
382
+ durationMs: 0,
383
+ cpuMs: {user: 0, system: 0, total: 0}
384
+ }
385
+
386
+ if (activity) span.activity = activity
387
+ if (declarationIndex !== undefined) span.declarationIndex = declarationIndex
388
+ if (declarationScopeId) span.declarationScopeId = declarationScopeId
389
+ if (safeFilePath) span.file = safeFilePath
390
+
391
+ if (currentContext?.attempt) {
392
+ currentContext.attempt.spans.push(span)
393
+ } else {
394
+ this._spans.push(span)
395
+ }
396
+
397
+ const spanContext = {
398
+ profiler: this,
399
+ attempt: currentContext?.attempt,
400
+ active: true,
401
+ attemptContexts: currentContext?.attemptContexts,
402
+ filePath: safeFilePath,
403
+ span,
404
+ spanStartedAt: startedAt,
405
+ spanCpuStartedAt: cpuStartedAt
406
+ }
407
+
408
+ spanContext.attemptContexts?.add(spanContext)
409
+ this._activeSpanContexts.add(spanContext)
410
+
411
+ try {
412
+ return await this._configuration
413
+ .getEnvironmentHandler()
414
+ .runWithTestProfileContext(spanContext, callback)
415
+ } finally {
416
+ if (spanContext.active) {
417
+ this.finishSpanContext(spanContext)
418
+ } else if (spanContext.attempt) {
419
+ this._unattributedLateEventCount++
420
+ }
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Finalizes an open span, including partial work closed by a timeout.
426
+ * @param {TestProfileAsyncContext} context - Span context.
427
+ * @returns {void}
428
+ */
429
+ finishSpanContext(context) {
430
+ if (!context.active || !context.span || context.spanStartedAt === undefined || !context.spanCpuStartedAt) return
431
+
432
+ context.active = false
433
+ context.span.durationMs = roundProfileDuration(this.now() - context.spanStartedAt)
434
+ context.span.cpuMs = this.cpuDuration(context.spanCpuStartedAt)
435
+ this.addPhaseDuration(context.span.phase, context.span.durationMs, context.span.cpuMs)
436
+ this.addFileDuration(context.filePath, context.span.phase, context.span.durationMs, Boolean(context.attempt))
437
+ this._activeSpanContexts.delete(context)
438
+ }
439
+
440
+ /**
441
+ * Checks both a nested span boundary and its owning attempt boundary.
442
+ * @param {TestProfileAsyncContext} context - Candidate profile context.
443
+ * @returns {boolean} - Whether events may still be attributed.
444
+ */
445
+ contextIsActive(context) {
446
+ return context.active && (!context.attempt || context.attempt.status === "running")
447
+ }
448
+
449
+ /**
450
+ * Runs a validated application-defined activity.
451
+ * @template T
452
+ * @param {TestProfileAsyncContext} context - Captured async context.
453
+ * @param {string} name - Validated activity name.
454
+ * @param {() => (T | Promise<T>)} callback - Activity callback.
455
+ * @returns {Promise<T>} - Callback result.
456
+ */
457
+ async profileActivity(context, name, callback) {
458
+ if (!this.contextIsActive(context)) {
459
+ this._unattributedLateEventCount++
460
+ return await callback()
461
+ }
462
+
463
+ const validatedName = validateTestActivityName(name)
464
+ let outputName = validatedName
465
+
466
+ if (!this._customActivityNames.has(validatedName)) {
467
+ if (this._customActivityNames.size >= MAX_CUSTOM_ACTIVITY_NAMES) {
468
+ outputName = "other"
469
+ } else {
470
+ this._customActivityNames.add(validatedName)
471
+ }
472
+ }
473
+
474
+ return await this.runSpan({phase: "custom", activity: outputName}, callback)
475
+ }
476
+
477
+ /**
478
+ * Measures a command-level phase outside an attempt.
479
+ * @template T
480
+ * @param {string} phase - Phase name.
481
+ * @param {() => (T | Promise<T>)} callback - Timed work.
482
+ * @param {{filePath?: string}} [metadata] - Optional source ownership.
483
+ * @returns {Promise<T>} - Callback result.
484
+ */
485
+ async measurePhase(phase, callback, metadata = {}) {
486
+ return await this.runSpan({phase, filePath: metadata.filePath}, callback)
487
+ }
488
+
489
+ /**
490
+ * Records one successful or failed physical database query attempt.
491
+ * @param {TestProfileAsyncContext} context - Context captured when the query began.
492
+ * @param {{durationMs: number, failed: boolean, sqlFingerprint: string, sqlOperation: string}} args - Query aggregate values.
493
+ * @returns {void}
494
+ */
495
+ recordDatabaseQuery(context, {durationMs, failed, sqlFingerprint, sqlOperation}) {
496
+ if (!this.contextIsActive(context)) {
497
+ this._unattributedLateEventCount++
498
+ return
499
+ }
500
+
501
+ const safeDurationMs = roundProfileDuration(durationMs)
502
+
503
+ this.addDatabaseQueryAggregate(this._database, {durationMs: safeDurationMs, failed})
504
+ if (context.span) {
505
+ context.span.database ||= this.emptyDatabaseAggregate()
506
+ this.addDatabaseQueryAggregate(context.span.database, {durationMs: safeDurationMs, failed})
507
+ }
508
+
509
+ const safeOperation = SAFE_SQL_OPERATIONS.has(sqlOperation) ? sqlOperation : "UNKNOWN"
510
+ const fingerprintKey = `${safeOperation}:${sqlFingerprint}`
511
+ let fingerprint = this._queryFingerprints.get(fingerprintKey)
512
+
513
+ if (!fingerprint && this._queryFingerprints.size < 50) {
514
+ fingerprint = {
515
+ hash: sqlFingerprint,
516
+ operation: safeOperation,
517
+ count: 0,
518
+ failedCount: 0,
519
+ totalMs: 0,
520
+ maxMs: 0
521
+ }
522
+ this._queryFingerprints.set(fingerprintKey, fingerprint)
523
+ }
524
+
525
+ if (fingerprint) {
526
+ fingerprint.count++
527
+ if (failed) fingerprint.failedCount++
528
+ fingerprint.totalMs += safeDurationMs
529
+ fingerprint.maxMs = Math.max(fingerprint.maxMs, safeDurationMs)
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Records a physical transaction action.
535
+ * @param {TestProfileAsyncContext} context - Context captured when the action began.
536
+ * @param {{action: "start" | "commit" | "rollback", durationMs: number, failed: boolean}} args - Transaction aggregate values.
537
+ * @returns {void}
538
+ */
539
+ recordDatabaseTransaction(context, {action, durationMs, failed}) {
540
+ if (!this.contextIsActive(context)) {
541
+ this._unattributedLateEventCount++
542
+ return
543
+ }
544
+
545
+ const aggregate = this._transactions[action]
546
+ const safeDurationMs = roundProfileDuration(durationMs)
547
+
548
+ aggregate.count++
549
+ if (failed) aggregate.failedCount++
550
+ aggregate.totalMs += safeDurationMs
551
+ aggregate.maxMs = Math.max(aggregate.maxMs, safeDurationMs)
552
+ }
553
+
554
+ /**
555
+ * Records one low-cardinality pool lifecycle delta.
556
+ * @param {TestProfileAsyncContext} context - Context captured when the operation began.
557
+ * @param {string} identifier - Logical pool identifier.
558
+ * @param {"connectionCreation" | "checkoutWait" | "checkoutTimeout" | "idleReap" | "idleReapDisposal" | "peakLiveConnections"} metric - Metric name.
559
+ * @param {{durationMs?: number, failed?: boolean, value?: number}} [values] - Aggregate values.
560
+ * @returns {void}
561
+ */
562
+ recordPoolMetric(context, identifier, metric, values = {}) {
563
+ if (!this.contextIsActive(context)) {
564
+ this._unattributedLateEventCount++
565
+ return
566
+ }
567
+
568
+ const aggregate = this.poolAggregate(this._pools, identifier)
569
+
570
+ this.addPoolMetric(aggregate, metric, values)
571
+
572
+ if (context.span) {
573
+ context.span.pools ||= []
574
+ const spanPools = new Map(context.span.pools.map((pool) => [pool.identifier, pool]))
575
+ const spanAggregate = this.poolAggregate(spanPools, identifier)
576
+
577
+ if (!context.span.pools.includes(spanAggregate)) context.span.pools.push(spanAggregate)
578
+ this.addPoolMetric(spanAggregate, metric, values)
579
+ }
580
+ }
581
+
582
+ /**
583
+ * Builds the final privacy-safe profile document.
584
+ * @param {object} args - Run result.
585
+ * @param {{discovered: number, executed: number, failed: number, passed: number}} args.counts - Run counts.
586
+ * @param {boolean} args.focused - Whether focused tests were selected.
587
+ * @param {string} args.status - Run status.
588
+ * @returns {ReturnType<typeof JSON.parse>} - Rich profile document.
589
+ */
590
+ finish({counts, focused, status}) {
591
+ if (this._finishedProfile) return this._finishedProfile
592
+
593
+ const totalDurationMs = roundProfileDuration(this.now() - this._startedAt)
594
+ const totalCpuMs = this.cpuDuration(this._cpuStartedAt)
595
+ const exclusivePhaseNames = BUILT_IN_PHASES
596
+ const measuredDurationMs = exclusivePhaseNames.reduce((sum, phase) => sum + this.phaseAggregate(phase).totalMs, 0)
597
+ const measuredCpuUserMs = exclusivePhaseNames.reduce((sum, phase) => sum + this.phaseAggregate(phase).cpuUserMs, 0)
598
+ const measuredCpuSystemMs = exclusivePhaseNames.reduce((sum, phase) => sum + this.phaseAggregate(phase).cpuSystemMs, 0)
599
+
600
+ this._phases.set("runner overhead", {
601
+ count: 1,
602
+ totalMs: Math.max(0, totalDurationMs - measuredDurationMs),
603
+ maxMs: Math.max(0, totalDurationMs - measuredDurationMs),
604
+ cpuUserMs: Math.max(0, totalCpuMs.user - measuredCpuUserMs),
605
+ cpuSystemMs: Math.max(0, totalCpuMs.system - measuredCpuSystemMs)
606
+ })
607
+ this._phases.set("total", {
608
+ count: 1,
609
+ totalMs: totalDurationMs,
610
+ maxMs: totalDurationMs,
611
+ cpuUserMs: totalCpuMs.user,
612
+ cpuSystemMs: totalCpuMs.system
613
+ })
614
+
615
+ const phases = Object.fromEntries([...this._phases.entries()].map(([phase, aggregate]) => [
616
+ phase,
617
+ this.publicAggregate(aggregate)
618
+ ]))
619
+ const files = [...this._files.values()]
620
+ .map((file) => ({
621
+ path: file.path,
622
+ importMs: roundProfileDuration(file.importMs),
623
+ hooksMs: roundProfileDuration(file.hooksMs),
624
+ testsMs: roundProfileDuration(file.testsMs),
625
+ attemptsMs: roundProfileDuration(file.attemptsMs),
626
+ totalMs: roundProfileDuration(file.totalMs)
627
+ }))
628
+ .sort((fileA, fileB) => fileA.path.localeCompare(fileB.path))
629
+ const timingManifest = Object.fromEntries(files.map((file) => [file.path, file.totalMs]))
630
+ const attempts = this._tests.reduce((sum, test) => sum + test.attempts.length, 0)
631
+
632
+ this._finishedProfile = {
633
+ schema: "velocious.test-profile",
634
+ schemaVersion: 1,
635
+ status,
636
+ selection: {...this._selection, focused},
637
+ counts: {...counts, attempts},
638
+ durationMs: totalDurationMs,
639
+ cpuMs: totalCpuMs,
640
+ phases,
641
+ files,
642
+ scopes: [...this._scopes].sort((scopeA, scopeB) => scopeA.id.localeCompare(scopeB.id)),
643
+ tests: this._tests.map((test) => ({
644
+ id: test.id,
645
+ file: test.file,
646
+ line: test.line,
647
+ durationMs: roundProfileDuration(test.attempts.reduce((sum, attempt) => sum + attempt.durationMs, 0)),
648
+ attempts: test.attempts
649
+ })),
650
+ spans: this._spans,
651
+ database: {
652
+ queryCount: this._database.queryCount,
653
+ failedQueryCount: this._database.failedQueryCount,
654
+ totalMs: roundProfileDuration(this._database.totalMs),
655
+ maxMs: roundProfileDuration(this._database.maxMs),
656
+ fingerprints: [...this._queryFingerprints.values()]
657
+ .map((fingerprint) => ({
658
+ ...fingerprint,
659
+ totalMs: roundProfileDuration(fingerprint.totalMs),
660
+ maxMs: roundProfileDuration(fingerprint.maxMs)
661
+ }))
662
+ .sort((fingerprintA, fingerprintB) => {
663
+ return fingerprintA.operation.localeCompare(fingerprintB.operation) || fingerprintA.hash.localeCompare(fingerprintB.hash)
664
+ }),
665
+ transactions: {
666
+ start: this.publicActionAggregate(this._transactions.start),
667
+ commit: this.publicActionAggregate(this._transactions.commit),
668
+ rollback: this.publicActionAggregate(this._transactions.rollback)
669
+ }
670
+ },
671
+ pools: [...this._pools.values()]
672
+ .map((pool) => this.publicPoolAggregate(pool))
673
+ .sort((poolA, poolB) => poolA.identifier.localeCompare(poolB.identifier)),
674
+ unattributedLateEventCount: this._unattributedLateEventCount,
675
+ timingManifest
676
+ }
677
+
678
+ return this._finishedProfile
679
+ }
680
+
681
+ /**
682
+ * Returns an internal phase aggregate.
683
+ * @param {string} phase - Phase name.
684
+ * @returns {InternalPhaseAggregate} - Aggregate.
685
+ */
686
+ phaseAggregate(phase) {
687
+ let aggregate = this._phases.get(phase)
688
+
689
+ if (!aggregate) {
690
+ aggregate = {count: 0, totalMs: 0, maxMs: 0, cpuUserMs: 0, cpuSystemMs: 0}
691
+ this._phases.set(phase, aggregate)
692
+ }
693
+
694
+ return aggregate
695
+ }
696
+
697
+ /**
698
+ * Adds a completed span to its aggregate phase.
699
+ * @param {string} phase - Phase name.
700
+ * @param {number} durationMs - Real duration.
701
+ * @param {{user: number, system: number, total: number}} cpuMs - CPU duration.
702
+ * @returns {void}
703
+ */
704
+ addPhaseDuration(phase, durationMs, cpuMs) {
705
+ const aggregate = this.phaseAggregate(phase)
706
+
707
+ aggregate.count++
708
+ aggregate.totalMs += durationMs
709
+ aggregate.maxMs = Math.max(aggregate.maxMs, durationMs)
710
+ aggregate.cpuUserMs += cpuMs.user
711
+ aggregate.cpuSystemMs += cpuMs.system
712
+ }
713
+
714
+ /**
715
+ * Adds an exclusive file-owned phase to timing-manifest weight.
716
+ * @param {string | undefined} filePath - Safe project-relative path.
717
+ * @param {string} phase - Phase name.
718
+ * @param {number} durationMs - Real duration.
719
+ * @param {boolean} [insideAttempt] - Whether the phase is already covered by complete attempt time.
720
+ * @returns {void}
721
+ */
722
+ addFileDuration(filePath, phase, durationMs, insideAttempt = false) {
723
+ if (!filePath) return
724
+ if (!["imports", "beforeAll", "beforeEach", "test body", "afterEach", "afterAll"].includes(phase)) return
725
+
726
+ let file = this._files.get(filePath)
727
+
728
+ if (!file) {
729
+ file = {path: filePath, importMs: 0, hooksMs: 0, testsMs: 0, attemptsMs: 0, totalMs: 0}
730
+ this._files.set(filePath, file)
731
+ }
732
+
733
+ if (phase === "imports") file.importMs += durationMs
734
+ if (phase === "test body") file.testsMs += durationMs
735
+ if (["beforeAll", "beforeEach", "afterEach", "afterAll"].includes(phase)) file.hooksMs += durationMs
736
+ if (!insideAttempt) file.totalMs += durationMs
737
+ }
738
+
739
+ /**
740
+ * Adds the complete cost of an attempt to its owning file weight.
741
+ * @param {string | undefined} filePath - Safe project-relative path.
742
+ * @param {number} durationMs - Attempt duration.
743
+ * @returns {void}
744
+ */
745
+ addFileAttemptDuration(filePath, durationMs) {
746
+ if (!filePath) return
747
+
748
+ let file = this._files.get(filePath)
749
+
750
+ if (!file) {
751
+ file = {path: filePath, importMs: 0, hooksMs: 0, testsMs: 0, attemptsMs: 0, totalMs: 0}
752
+ this._files.set(filePath, file)
753
+ }
754
+
755
+ file.attemptsMs += durationMs
756
+ file.totalMs += durationMs
757
+ }
758
+
759
+ /**
760
+ * Calculates process CPU time since a start sample.
761
+ * @param {ProcessCpuUsage} start - CPU start sample.
762
+ * @returns {{user: number, system: number, total: number}} - Millisecond CPU duration.
763
+ */
764
+ cpuDuration(start) {
765
+ const cpu = process.cpuUsage(start)
766
+ const user = roundProfileDuration(cpu.user / 1000)
767
+ const system = roundProfileDuration(cpu.system / 1000)
768
+
769
+ return {user, system, total: roundProfileDuration(user + system)}
770
+ }
771
+
772
+ /**
773
+ * Converts an internal aggregate to public rounded values.
774
+ * @param {InternalPhaseAggregate} [aggregate] - Internal aggregate.
775
+ * @returns {{count: number, totalMs: number, maxMs: number, cpuMs: {user: number, system: number, total: number}}} - Public aggregate.
776
+ */
777
+ publicAggregate(aggregate = {count: 0, totalMs: 0, maxMs: 0, cpuUserMs: 0, cpuSystemMs: 0}) {
778
+ const user = roundProfileDuration(aggregate.cpuUserMs)
779
+ const system = roundProfileDuration(aggregate.cpuSystemMs)
780
+
781
+ return {
782
+ count: aggregate.count,
783
+ totalMs: roundProfileDuration(aggregate.totalMs),
784
+ maxMs: roundProfileDuration(aggregate.maxMs),
785
+ cpuMs: {user, system, total: roundProfileDuration(user + system)}
786
+ }
787
+ }
788
+
789
+ /**
790
+ * Returns an empty query aggregate.
791
+ * @returns {ProfileDatabaseAggregate} - Empty aggregate.
792
+ */
793
+ emptyDatabaseAggregate() {
794
+ return {queryCount: 0, failedQueryCount: 0, totalMs: 0, maxMs: 0}
795
+ }
796
+
797
+ /**
798
+ * Adds a query attempt to a database aggregate.
799
+ * @param {ProfileDatabaseAggregate} aggregate - Aggregate to update.
800
+ * @param {{durationMs: number, failed: boolean}} args - Attempt values.
801
+ * @returns {void}
802
+ */
803
+ addDatabaseQueryAggregate(aggregate, {durationMs, failed}) {
804
+ aggregate.queryCount++
805
+ if (failed) aggregate.failedQueryCount++
806
+ aggregate.totalMs += durationMs
807
+ aggregate.maxMs = Math.max(aggregate.maxMs, durationMs)
808
+ }
809
+
810
+ /**
811
+ * Returns an empty action aggregate.
812
+ * @returns {ProfileActionAggregate} - Empty aggregate.
813
+ */
814
+ phaseAggregateShape() {
815
+ return {count: 0, failedCount: 0, totalMs: 0, maxMs: 0}
816
+ }
817
+
818
+ /**
819
+ * Rounds a transaction or pool action aggregate.
820
+ * @param {ProfileActionAggregate} aggregate - Internal aggregate.
821
+ * @returns {ProfileActionAggregate} - Public aggregate.
822
+ */
823
+ publicActionAggregate(aggregate) {
824
+ return {
825
+ count: aggregate.count,
826
+ failedCount: aggregate.failedCount,
827
+ totalMs: roundProfileDuration(aggregate.totalMs),
828
+ maxMs: roundProfileDuration(aggregate.maxMs)
829
+ }
830
+ }
831
+
832
+ /**
833
+ * Returns an empty pool aggregate.
834
+ * @param {string} [identifier] - Logical pool identifier.
835
+ * @returns {ProfilePoolAggregate} - Empty aggregate.
836
+ */
837
+ emptyPoolAggregate(identifier = "") {
838
+ return {
839
+ identifier,
840
+ connectionCreation: this.phaseAggregateShape(),
841
+ checkoutWait: {count: 0, totalMs: 0, maxMs: 0},
842
+ checkoutTimeoutCount: 0,
843
+ idleReap: {...this.phaseAggregateShape(), disposalCount: 0},
844
+ peakLiveConnections: 0
845
+ }
846
+ }
847
+
848
+ /**
849
+ * Gets or creates a pool aggregate in a map.
850
+ * @param {Map<string, ProfilePoolAggregate>} pools - Pool map.
851
+ * @param {string} identifier - Logical pool identifier.
852
+ * @returns {ProfilePoolAggregate} - Pool aggregate.
853
+ */
854
+ poolAggregate(pools, identifier) {
855
+ let aggregate = pools.get(identifier)
856
+
857
+ if (!aggregate) {
858
+ aggregate = this.emptyPoolAggregate(identifier)
859
+ pools.set(identifier, aggregate)
860
+ }
861
+
862
+ return aggregate
863
+ }
864
+
865
+ /**
866
+ * Applies one pool metric delta.
867
+ * @param {ProfilePoolAggregate} aggregate - Pool aggregate.
868
+ * @param {"connectionCreation" | "checkoutWait" | "checkoutTimeout" | "idleReap" | "idleReapDisposal" | "peakLiveConnections"} metric - Metric name.
869
+ * @param {{durationMs?: number, failed?: boolean, value?: number}} values - Metric values.
870
+ * @returns {void}
871
+ */
872
+ addPoolMetric(aggregate, metric, values) {
873
+ const durationMs = roundProfileDuration(values.durationMs ?? 0)
874
+
875
+ if (metric === "connectionCreation" || metric === "idleReap") {
876
+ const actionAggregate = metric === "connectionCreation" ? aggregate.connectionCreation : aggregate.idleReap
877
+
878
+ actionAggregate.count++
879
+ if (values.failed) actionAggregate.failedCount++
880
+ actionAggregate.totalMs += durationMs
881
+ actionAggregate.maxMs = Math.max(actionAggregate.maxMs, durationMs)
882
+ } else if (metric === "checkoutWait") {
883
+ aggregate.checkoutWait.count++
884
+ aggregate.checkoutWait.totalMs += durationMs
885
+ aggregate.checkoutWait.maxMs = Math.max(aggregate.checkoutWait.maxMs, durationMs)
886
+ } else if (metric === "checkoutTimeout") {
887
+ aggregate.checkoutTimeoutCount++
888
+ } else if (metric === "idleReapDisposal") {
889
+ aggregate.idleReap.disposalCount++
890
+ } else if (metric === "peakLiveConnections") {
891
+ aggregate.peakLiveConnections = Math.max(aggregate.peakLiveConnections, values.value ?? 0)
892
+ }
893
+ }
894
+
895
+ /**
896
+ * Rounds a pool aggregate for output.
897
+ * @param {ProfilePoolAggregate} aggregate - Internal aggregate.
898
+ * @returns {ProfilePoolAggregate} - Public aggregate.
899
+ */
900
+ publicPoolAggregate(aggregate) {
901
+ return {
902
+ identifier: aggregate.identifier,
903
+ connectionCreation: this.publicActionAggregate(aggregate.connectionCreation),
904
+ checkoutWait: {
905
+ count: aggregate.checkoutWait.count,
906
+ totalMs: roundProfileDuration(aggregate.checkoutWait.totalMs),
907
+ maxMs: roundProfileDuration(aggregate.checkoutWait.maxMs)
908
+ },
909
+ checkoutTimeoutCount: aggregate.checkoutTimeoutCount,
910
+ idleReap: {
911
+ ...this.publicActionAggregate(aggregate.idleReap),
912
+ disposalCount: aggregate.idleReap.disposalCount
913
+ },
914
+ peakLiveConnections: aggregate.peakLiveConnections
915
+ }
916
+ }
917
+ }