velocious 1.0.653 → 1.0.655

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 (31) hide show
  1. package/README.md +4 -2
  2. package/build/environment-handlers/node/cli/commands/test.js +2 -1
  3. package/build/http-server/client/index.js +67 -5
  4. package/build/http-server/client/request-buffer/index.js +15 -1
  5. package/build/http-server/client/response-compression.js +6 -8
  6. package/build/src/environment-handlers/node/cli/commands/test.d.ts.map +1 -1
  7. package/build/src/environment-handlers/node/cli/commands/test.js +4 -2
  8. package/build/src/http-server/client/index.d.ts +9 -1
  9. package/build/src/http-server/client/index.d.ts.map +1 -1
  10. package/build/src/http-server/client/index.js +68 -6
  11. package/build/src/http-server/client/request-buffer/index.d.ts.map +1 -1
  12. package/build/src/http-server/client/request-buffer/index.js +16 -2
  13. package/build/src/http-server/client/response-compression.d.ts +4 -2
  14. package/build/src/http-server/client/response-compression.d.ts.map +1 -1
  15. package/build/src/http-server/client/response-compression.js +7 -8
  16. package/build/src/testing/test-runner.d.ts +23 -0
  17. package/build/src/testing/test-runner.d.ts.map +1 -1
  18. package/build/src/testing/test-runner.js +29 -2
  19. package/build/src/testing/velocious-runner-reporter.d.ts +7 -0
  20. package/build/src/testing/velocious-runner-reporter.d.ts.map +1 -1
  21. package/build/src/testing/velocious-runner-reporter.js +54 -12
  22. package/build/testing/test-runner.js +33 -1
  23. package/build/testing/velocious-runner-reporter.js +52 -11
  24. package/package.json +4 -4
  25. package/scripts/browser-test-session.js +21 -23
  26. package/src/environment-handlers/node/cli/commands/test.js +2 -1
  27. package/src/http-server/client/index.js +67 -5
  28. package/src/http-server/client/request-buffer/index.js +15 -1
  29. package/src/http-server/client/response-compression.js +6 -8
  30. package/src/testing/test-runner.js +33 -1
  31. package/src/testing/velocious-runner-reporter.js +52 -11
@@ -8,7 +8,7 @@ import EventEmitter from "../../utils/event-emitter.js"
8
8
  import Logger from "../../logger.js"
9
9
  import Request from "./request.js"
10
10
  import RequestRunner from "./request-runner.js"
11
- import {applyResponseCompression} from "./response-compression.js"
11
+ import {addAcceptEncodingToVary, applyResponseCompression, negotiateContentEncoding} from "./response-compression.js"
12
12
  import WebsocketSession from "./websocket-session.js"
13
13
 
14
14
  /**
@@ -384,7 +384,15 @@ export default class VeoliciousHttpServerClient {
384
384
  }
385
385
 
386
386
  /**
387
- * Runs send response.
387
+ * Sends a finished response to the client. Owns the framework-owned
388
+ * `Vary: Accept-Encoding` dimension (emitted for every selected
389
+ * representation — transformed, identity, 406, and file — and for
390
+ * header-present and header-absent requests alike, so it is stable across
391
+ * requests on the same connection; never added when compression is disabled,
392
+ * the response is truly bodyless, or the application supplied a fixed
393
+ * `Content-Encoding`) and the file 406 rule (a sendFile response whose
394
+ * client forbids identity is answered with the empty 406, the file is never
395
+ * opened or streamed, and `onFinished` settles once as "completed").
388
396
  * @param {RequestRunner} requestRunner - Request runner.
389
397
  * @returns {Promise<void>} - Resolves when complete.
390
398
  */
@@ -436,12 +444,41 @@ export default class VeoliciousHttpServerClient {
436
444
  /** @type {string | Uint8Array | null} */
437
445
  let bodyToEmit = body
438
446
 
447
+ // The response representation can depend on the client's Accept-Encoding:
448
+ // the same request is answered with an identity body (or a file) when
449
+ // identity is acceptable and with an empty 406 when it is forbidden.
450
+ const compression = this.configuration.getHttpServerCompression()
451
+ const negotiated = compression.enabled ? negotiateContentEncoding(request.header("accept-encoding")) : undefined
452
+ // An application-supplied Content-Encoding is an application-owned
453
+ // representation contract, captured before the framework may add its own.
454
+ // A file carrying one is a fixed, application-owned representation: the
455
+ // framework neither negotiates it nor re-advertises it, so it is never a
456
+ // candidate for the identity-only 406.
457
+ const hasApplicationContentEncoding = response.getHeader("Content-Encoding").length > 0
458
+ // A file response only ever serves the identity representation: whenever
459
+ // the client forbids identity (including the not-acceptable case where no
460
+ // coding applies) and the file does not carry an application-supplied
461
+ // Content-Encoding, the file is rejected with the empty 406. A truly
462
+ // bodyless status selects no representation, so it is never rejected here.
463
+ const isFileNotAcceptable = hasFilePath && !!negotiated && ("notAcceptable" in negotiated || negotiated.identityAcceptable === false) && hasApplicationContentEncoding === false && !isBodylessStatus
464
+
439
465
  if (!isBodylessStatus) {
440
466
  let contentLength
441
467
 
442
468
  if (hasFilePath) {
443
- const stats = await fs.stat(filePath)
444
- contentLength = stats.size
469
+ if (isFileNotAcceptable) {
470
+ // The client forbids identity and files are only ever sent identity:
471
+ // answer with the same empty 406 every other representation path
472
+ // uses. The file is never opened or streamed; onFinished is settled
473
+ // below, after the committed 406 headers are emitted.
474
+ response.setStatus(406)
475
+ response.setBody("")
476
+ bodyToEmit = ""
477
+ contentLength = 0
478
+ } else {
479
+ const stats = await fs.stat(filePath)
480
+ contentLength = stats.size
481
+ }
445
482
  } else {
446
483
  // String bodies are UTF-8 framed, so the buffered bytes are the UTF-8 encoding;
447
484
  // Uint8Array bodies are already the exact wire bytes.
@@ -474,6 +511,18 @@ export default class VeoliciousHttpServerClient {
474
511
  response.setHeader("Content-Length", contentLength)
475
512
  }
476
513
 
514
+ // Framework-owned Vary dimension: whenever compression is enabled and a
515
+ // representation was selected, the response depends on Accept-Encoding,
516
+ // so caches must key on it. Applied identically for every outcome
517
+ // (transformed, identity, 406, file) and for header-present and
518
+ // header-absent requests alike, so the header is stable across requests on
519
+ // the same connection. A truly bodyless response selects no representation
520
+ // and carries no dimension; an application-supplied Content-Encoding keeps
521
+ // the representation contract application-owned and is never re-advertised.
522
+ if (negotiated && !isBodylessStatus && hasApplicationContentEncoding === false) {
523
+ addAcceptEncodingToVary(response)
524
+ }
525
+
477
526
  response.setHeader("Date", date.toUTCString())
478
527
  response.setHeader("Server", "Velocious")
479
528
 
@@ -492,8 +541,21 @@ export default class VeoliciousHttpServerClient {
492
541
  this.events.emit("output", headers)
493
542
  this.logger.debug(() => ["sendResponse headers emitted", {clientCount: this.clientCount, headersLength: headers.length}])
494
543
 
495
- if (isBodylessStatus) {
544
+ // A negotiated file 406 is committed above (status 406, empty body) and its
545
+ // headers were just emitted: settle onFinished now, so the callback runs
546
+ // after the response is committed — a slow or app-stopping callback cannot
547
+ // delay or block delivery of the already-emitted 406. The file is never
548
+ // opened, streamed, or reported (no file event), and the callback settles
549
+ // exactly once as "completed".
550
+ if (isFileNotAcceptable) {
551
+ this.logger.debug(() => ["sendResponse file body suppressed for 406", {clientCount: this.clientCount, filePath}])
552
+ await this.runFileOnFinished({filePath, onFinished: fileOnFinished, result: "completed"})
553
+ } else if (isBodylessStatus) {
496
554
  this.logger.debug(() => ["sendResponse body suppressed for no-body status", {clientCount: this.clientCount, statusCode: response.getStatusCode()}])
555
+ // A bodyless status (1xx/204/304) selects no representation, so no file
556
+ // body or framework Vary is emitted. The file-ownership path still settles
557
+ // onFinished exactly once as "completed" (nothing was aborted) — even when
558
+ // the client forbids identity — preserving the pre-change settlement.
497
559
  if (hasFilePath) await this.sendFileOutput(filePath, false, fileOnFinished)
498
560
  } else if (isHeadRequest) {
499
561
  this.logger.debug(() => ["sendResponse body suppressed for HEAD request", {clientCount: this.clientCount}])
@@ -8,6 +8,12 @@ import Logger from "../../../logger.js"
8
8
  import ParamsToObject from "../params-to-object.js"
9
9
  import querystring from "querystring"
10
10
 
11
+ /**
12
+ * Request header fields whose repeated wire fields combine into one value
13
+ * (RFC 9110 §5.3) before the server consumes them.
14
+ * @type {Set<string>} */
15
+ const COMBINING_HEADER_FIELDS = new Set(["accept-encoding"])
16
+
11
17
  /**
12
18
  * Runs truncate preview.
13
19
  * @param {string | undefined} input - Input string.
@@ -367,8 +373,16 @@ export default class RequestBuffer {
367
373
  */
368
374
  addHeader(header) {
369
375
  const formattedName = header.getFormattedName()
376
+ const existingHeader = this.headersByName[formattedName]
370
377
 
371
- this.headersByName[formattedName] = header
378
+ // RFC 9110 §5.3: a field may be repeated; its value is the concatenation of
379
+ // all field values separated by commas, in wire order. Only Accept-Encoding
380
+ // is consumed as a combined field by the server.
381
+ if (existingHeader && COMBINING_HEADER_FIELDS.has(formattedName)) {
382
+ existingHeader.value += `, ${header.getValue()}`
383
+ } else {
384
+ this.headersByName[formattedName] = header
385
+ }
372
386
 
373
387
  if (formattedName == "content-length") this.contentLength = parseInt(header.getValue())
374
388
  }
@@ -22,9 +22,9 @@ const COMPRESSIBLE_EXACT_MEDIA_TYPES = new Set([
22
22
 
23
23
  /**
24
24
  * RFC 9110 §12.4.2 qvalue grammar: `0` or `1` with at most three fractional
25
- * digits, and only zeros after `1`.
25
+ * digits (the boundary forms `0.` and `1.` are valid), and only zeros after `1`.
26
26
  * @type {RegExp} */
27
- const QVALUE_PATTERN = /^(?:0(?:\.\d{1,3})?|1(?:\.0{1,3})?)$/u
27
+ const QVALUE_PATTERN = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/u
28
28
 
29
29
  /**
30
30
  * Runs parse accept encoding.
@@ -118,7 +118,9 @@ export function isCompressibleContentType(contentType) {
118
118
  /**
119
119
  * Merges Accept-Encoding into the response Vary header case-insensitively and
120
120
  * without duplicates. An existing `Vary: *` already covers every request header
121
- * and is preserved as-is.
121
+ * and is preserved as-is. Called by the response sender for every
122
+ * framework-selected representation so the header is identical for every
123
+ * request on the same connection.
122
124
  * @param {import("./response.js").default} response - Response instance.
123
125
  * @returns {void} - No return value.
124
126
  */
@@ -160,7 +162,7 @@ export function addAcceptEncodingToVary(response) {
160
162
  * @param {import("../../configuration-types.js").NormalizedHttpCompressionConfiguration} args.compression - Normalized compression configuration.
161
163
  * @param {import("./request.js").default | import("./websocket-request.js").default} args.request - Request object.
162
164
  * @param {import("./response.js").default} args.response - Response instance.
163
- * @returns {Promise<{outcome: "identity"} | {outcome: "compressed", body: Buffer} | {outcome: "not-acceptable"}>} - Compression outcome.
165
+ * @returns {Promise<{outcome: "identity"} | {outcome: "compressed", body: Buffer} | {outcome: "not-acceptable"}>} - Compression outcome. The caller owns the Vary header and the file/406 representation decisions.
164
166
  */
165
167
  export async function applyResponseCompression({bodyBuffer, compression, request, response}) {
166
168
  if (!compression.enabled) return {outcome: "identity"}
@@ -203,10 +205,6 @@ export async function applyResponseCompression({bodyBuffer, compression, request
203
205
  return negotiated.identityAcceptable ? {outcome: "identity"} : {outcome: "not-acceptable"}
204
206
  }
205
207
 
206
- // The representation now depends on the request's Accept-Encoding, even when this
207
- // particular response ends up identity (missing header, higher-q identity, below threshold).
208
- addAcceptEncodingToVary(response)
209
-
210
208
  if (negotiated.encoding == "identity") return {outcome: "identity"}
211
209
 
212
210
  // Below the threshold the smaller identity representation is sent instead — but only
@@ -142,6 +142,8 @@ import VelociousTestArguments from "./velocious-test-arguments.js"
142
142
  * @property {import("../database/pool/base.js").TestSharedConnectionRegistration | undefined} sharedRegistration - Physical-key shared registration once published.
143
143
  */
144
144
 
145
+ const testingPackageDirectory = path.dirname(fileURLToPath(import.meta.resolve("@velocious/testing/package.json")))
146
+
145
147
  /**
146
148
  * Runs to file slug.
147
149
  * @param {string} value - Value to sanitize.
@@ -197,6 +199,8 @@ export default class TestRunner {
197
199
  this._successfulTests = 0
198
200
  this._testsCount = 0
199
201
  this._failedTestDetails = []
202
+ /** @type {import("@velocious/testing/runner").NonRunTestResult[]} */
203
+ this._notRunTestDetails = []
200
204
  /** @type {{fullDescription: string, filePath: string, line: number} | null} */
201
205
  this._lastTestContext = null
202
206
  /** @type {Array<{fullDescription: string, filePath: string, line: number, durationMs: number}>} */
@@ -1007,6 +1011,25 @@ export default class TestRunner {
1007
1011
  return this._failedTests
1008
1012
  }
1009
1013
 
1014
+ /**
1015
+ * Counts selected tests blocked by a terminal resource.
1016
+ * @returns {number} - Selected tests not executed because a shared resource failed.
1017
+ */
1018
+ getNotRunTests() { return this._notRunTestDetails.length }
1019
+
1020
+ /**
1021
+ * Returns runtime non-run attribution.
1022
+ * @returns {import("@velocious/testing/runner").NonRunTestResult[]} - Runtime non-run details with the originating failure.
1023
+ */
1024
+ getNotRunTestDetails() { return this._notRunTestDetails }
1025
+
1026
+ /**
1027
+ * Records a selected test that did not execute.
1028
+ * @param {import("@velocious/testing/runner").NonRunTestResult} result - Terminal-resource non-run record.
1029
+ * @returns {void}
1030
+ */
1031
+ recordNotRunTest(result) { this._notRunTestDetails.push(result) }
1032
+
1010
1033
  /**
1011
1034
  * Runs get failed test details.
1012
1035
  * @returns {FailedTestDetail[]} - Failed test details.
@@ -1087,6 +1110,14 @@ export default class TestRunner {
1087
1110
  return this._packageResult?.tests.length ?? this._testDurations.length
1088
1111
  }
1089
1112
 
1113
+ /**
1114
+ * Distinguishes an empty selection from a failure before selected cases execute.
1115
+ * @returns {boolean} - Whether selection matched no declarations.
1116
+ */
1117
+ hasNoMatches() {
1118
+ return this._packageResult?.noMatches === true
1119
+ }
1120
+
1090
1121
  /**
1091
1122
  * Returns the tests recorded during the run, slowest first.
1092
1123
  * @param {number} [limit] - Maximum number of tests to return (0 returns all).
@@ -1105,6 +1136,7 @@ export default class TestRunner {
1105
1136
  async prepare() {
1106
1137
  this.anyTestsFocussed = false
1107
1138
  this._failedTests = 0
1139
+ this._notRunTestDetails = []
1108
1140
  this._successfulTests = 0
1109
1141
  this._testsCount = 0
1110
1142
  this._abortRemainingTests = false
@@ -1177,7 +1209,7 @@ export default class TestRunner {
1177
1209
 
1178
1210
  if (portablePath.endsWith("/src/testing/test-runner.js")) continue
1179
1211
  if (portablePath.endsWith("/src/testing/test.js")) continue
1180
- if (portablePath.includes("/node_modules/@velocious/testing/")) continue
1212
+ if (resolvedFilePath.startsWith(`${testingPackageDirectory}${path.sep}`)) continue
1181
1213
 
1182
1214
  return {filePath: resolvedFilePath, line: Number(match[2])}
1183
1215
  }
@@ -1,7 +1,6 @@
1
1
  // @ts-check
2
2
 
3
3
  import { addTrackedStackToError } from "../utils/with-tracked-stack.js"
4
- import BacktraceCleaner from "../utils/backtrace-cleaner-node.js"
5
4
  import picocolors from "picocolors"
6
5
  import restArgsError from "../utils/rest-args-error.js"
7
6
  import { testEvents } from "./test.js"
@@ -21,6 +20,8 @@ function errorFromPackageRecord(errorRecord) {
21
20
 
22
21
  error.name = errorRecord.name
23
22
  if (errorRecord.stack) error.stack = errorRecord.stack
23
+ if (errorRecord.cause) error.cause = errorFromPackageRecord(errorRecord.cause)
24
+ if (errorRecord.terminalResource) Object.assign(error, {terminalResource: errorRecord.terminalResource})
24
25
 
25
26
  return error
26
27
  }
@@ -68,7 +69,27 @@ export default class VelociousRunnerReporter {
68
69
  return
69
70
  }
70
71
 
71
- if (event.type === "run:finish") this.testRunner.recordPackageResult(event.result)
72
+ if (event.type === "test:not-run") {
73
+ const test = this.testRunner.findTestDeclaration(event.test.fullName)
74
+ if (!test) throw new Error(`Package not-run result did not match a declaration: ${event.test.fullName}`)
75
+ this.testRunner.recordNotRunTest(event.test)
76
+ this.testRunner.completeTestDeclaration(test)
77
+ console.error(`Not run: ${event.test.fullName} (terminal resource failure in ${event.test.reason.fullName})`)
78
+ await this.emitEvent("testNotRun", {
79
+ configuration: this.testRunner.getConfiguration(),
80
+ test: event.test,
81
+ testRunner: this.testRunner
82
+ })
83
+ return
84
+ }
85
+
86
+ if (event.type === "run:finish") {
87
+ this.testRunner.recordPackageResult(event.result)
88
+ for (const failure of event.result.errors) {
89
+ console.error(`Suite ${failure.phase} failed: ${failure.suite}`)
90
+ this.printErrorCauses(errorFromPackageRecord(failure.error))
91
+ }
92
+ }
72
93
  }
73
94
 
74
95
  /**
@@ -95,7 +116,7 @@ export default class VelociousRunnerReporter {
95
116
  const failed = outcome?.failed ?? Boolean(attempt.error)
96
117
  const error = outcome?.error
97
118
  const retriesUsed = Math.min(attempt.attemptNumber, retryCount)
98
- const willRetry = failed && !outcome?.abortRemainingTests && attempt.attemptNumber <= retryCount
119
+ const willRetry = failed && !event.terminalFailure && !outcome?.abortRemainingTests && attempt.attemptNumber <= retryCount
99
120
  const {descriptions, testDescription} = this.testRunner.testMetadata(test)
100
121
  const compatibility = this.testRunner.testData(test)
101
122
 
@@ -245,15 +266,8 @@ export default class VelociousRunnerReporter {
245
266
 
246
267
  if (error instanceof Error) {
247
268
  console.error(picocolors.red(`${leftPadding} Test failed: ${error.message}`))
248
- addTrackedStackToError(error)
249
-
250
- const backtraceCleaner = new BacktraceCleaner(error)
251
- const cleanedStack = backtraceCleaner.getCleanedStack()
252
- const stackLines = cleanedStack?.split("\n")
269
+ this.printErrorCauses(error)
253
270
 
254
- if (stackLines) {
255
- for (const stackLine of stackLines) console.error(picocolors.red(`${leftPadding} ${stackLine}`))
256
- }
257
271
  } else {
258
272
  console.error(picocolors.red(`${leftPadding} Test failed with a ${typeof error}: ${String(error)}`))
259
273
  }
@@ -274,6 +288,33 @@ export default class VelociousRunnerReporter {
274
288
  testRunner.printRerunCommand({descriptions, testDescription, testData, leftPadding})
275
289
  }
276
290
 
291
+ /**
292
+ * Prints complete primary and secondary stacks once, including cyclic cause graphs.
293
+ * @param {unknown} error - Thrown value at the reporting boundary.
294
+ * @param {Set<Error>} [reported] - Error identities already printed.
295
+ * @returns {void}
296
+ */
297
+ printErrorCauses(error, reported = new Set()) {
298
+ if (!(error instanceof Error)) {
299
+ console.error(String(error))
300
+ return
301
+ }
302
+ if (reported.has(error)) return
303
+ reported.add(error)
304
+ addTrackedStackToError(error)
305
+ console.error(error.stack || `${error.name}: ${error.message}`)
306
+ if (error.cause !== undefined) {
307
+ console.error("Caused by:")
308
+ this.printErrorCauses(error.cause, reported)
309
+ }
310
+ if (error instanceof AggregateError) {
311
+ for (const secondary of error.errors) {
312
+ console.error("Related failure:")
313
+ this.printErrorCauses(secondary, reported)
314
+ }
315
+ }
316
+ }
317
+
277
318
  /**
278
319
  * Emits one legacy event and awaits listeners in registration order.
279
320
  * @param {string} eventName - Event name.