velocious 1.0.591 → 1.0.593

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 (118) hide show
  1. package/README.md +9 -1
  2. package/build/configuration-types.js +1 -1
  3. package/build/database/drivers/base.js +53 -34
  4. package/build/database/drivers/mssql/index.js +32 -0
  5. package/build/database/drivers/mysql/index.js +17 -0
  6. package/build/database/drivers/pgsql/index.js +11 -0
  7. package/build/database/drivers/sqlite/base.js +12 -0
  8. package/build/database/drivers/sqlite/connection-sql-js.js +3 -2
  9. package/build/database/drivers/sqlite/index.js +7 -1
  10. package/build/database/drivers/sqlite/index.native.js +9 -3
  11. package/build/database/drivers/sqlite/index.web.js +6 -3
  12. package/build/environment-handlers/base.js +18 -0
  13. package/build/environment-handlers/node.js +32 -0
  14. package/build/src/configuration-types.d.ts +2 -2
  15. package/build/src/configuration-types.js +2 -2
  16. package/build/src/database/drivers/base.d.ts +11 -0
  17. package/build/src/database/drivers/base.d.ts.map +1 -1
  18. package/build/src/database/drivers/base.js +54 -37
  19. package/build/src/database/drivers/mssql/index.d.ts +7 -0
  20. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  21. package/build/src/database/drivers/mssql/index.js +15 -1
  22. package/build/src/database/drivers/mysql/index.d.ts +7 -0
  23. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  24. package/build/src/database/drivers/mysql/index.js +15 -1
  25. package/build/src/database/drivers/pgsql/index.d.ts +6 -0
  26. package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
  27. package/build/src/database/drivers/pgsql/index.js +10 -1
  28. package/build/src/database/drivers/sqlite/base.d.ts +7 -0
  29. package/build/src/database/drivers/sqlite/base.d.ts.map +1 -1
  30. package/build/src/database/drivers/sqlite/base.js +11 -1
  31. package/build/src/database/drivers/sqlite/connection-sql-js.d.ts +4 -1
  32. package/build/src/database/drivers/sqlite/connection-sql-js.d.ts.map +1 -1
  33. package/build/src/database/drivers/sqlite/connection-sql-js.js +4 -3
  34. package/build/src/database/drivers/sqlite/index.d.ts +2 -1
  35. package/build/src/database/drivers/sqlite/index.d.ts.map +1 -1
  36. package/build/src/database/drivers/sqlite/index.js +7 -2
  37. package/build/src/database/drivers/sqlite/index.native.d.ts +2 -1
  38. package/build/src/database/drivers/sqlite/index.native.d.ts.map +1 -1
  39. package/build/src/database/drivers/sqlite/index.native.js +9 -4
  40. package/build/src/database/drivers/sqlite/index.web.d.ts +2 -1
  41. package/build/src/database/drivers/sqlite/index.web.d.ts.map +1 -1
  42. package/build/src/database/drivers/sqlite/index.web.js +7 -4
  43. package/build/src/environment-handlers/base.d.ts +16 -0
  44. package/build/src/environment-handlers/base.d.ts.map +1 -1
  45. package/build/src/environment-handlers/base.js +17 -1
  46. package/build/src/environment-handlers/node.d.ts +20 -0
  47. package/build/src/environment-handlers/node.d.ts.map +1 -1
  48. package/build/src/environment-handlers/node.js +29 -1
  49. package/build/src/sync/conflict-strategy.d.ts.map +1 -1
  50. package/build/src/sync/conflict-strategy.js +7 -1
  51. package/build/src/sync/local-mutation-log.d.ts +10 -0
  52. package/build/src/sync/local-mutation-log.d.ts.map +1 -1
  53. package/build/src/sync/local-mutation-log.js +21 -1
  54. package/build/src/sync/sync-api-client-types.d.ts +15 -0
  55. package/build/src/sync/sync-api-client-types.d.ts.map +1 -1
  56. package/build/src/sync/sync-api-client-types.js +4 -1
  57. package/build/src/sync/sync-api-client.d.ts +91 -0
  58. package/build/src/sync/sync-api-client.d.ts.map +1 -1
  59. package/build/src/sync/sync-api-client.js +184 -1
  60. package/build/src/sync/sync-client-types.d.ts +57 -0
  61. package/build/src/sync/sync-client-types.d.ts.map +1 -1
  62. package/build/src/sync/sync-client-types.js +16 -1
  63. package/build/src/sync/sync-client.d.ts +56 -7
  64. package/build/src/sync/sync-client.d.ts.map +1 -1
  65. package/build/src/sync/sync-client.js +180 -15
  66. package/build/src/sync/sync-envelope-replay-service.d.ts +31 -2
  67. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  68. package/build/src/sync/sync-envelope-replay-service.js +98 -16
  69. package/build/src/sync/sync-model-change-feed-service.d.ts.map +1 -1
  70. package/build/src/sync/sync-model-change-feed-service.js +3 -4
  71. package/build/src/sync/sync-replay-persisted-data.d.ts +26 -0
  72. package/build/src/sync/sync-replay-persisted-data.d.ts.map +1 -0
  73. package/build/src/sync/sync-replay-persisted-data.js +43 -0
  74. package/build/src/testing/shared-transaction-broker.d.ts +14 -0
  75. package/build/src/testing/shared-transaction-broker.d.ts.map +1 -1
  76. package/build/src/testing/shared-transaction-broker.js +44 -7
  77. package/build/src/testing/shared-transaction-connection-coordinator.d.ts +5 -4
  78. package/build/src/testing/shared-transaction-connection-coordinator.d.ts.map +1 -1
  79. package/build/src/testing/shared-transaction-connection-coordinator.js +21 -10
  80. package/build/sync/conflict-strategy.js +3 -0
  81. package/build/sync/local-mutation-log.js +24 -0
  82. package/build/sync/sync-api-client-types.js +3 -0
  83. package/build/sync/sync-api-client.js +203 -0
  84. package/build/sync/sync-client-types.js +16 -0
  85. package/build/sync/sync-client.js +198 -14
  86. package/build/sync/sync-envelope-replay-service.js +109 -12
  87. package/build/sync/sync-model-change-feed-service.js +2 -4
  88. package/build/sync/sync-replay-persisted-data.js +50 -0
  89. package/build/testing/shared-transaction-broker.js +48 -7
  90. package/build/testing/shared-transaction-connection-coordinator.js +23 -9
  91. package/build/tsconfig.tsbuildinfo +1 -1
  92. package/package.json +1 -1
  93. package/scripts/browser-test-session.js +489 -0
  94. package/scripts/prewarm-chromedriver.js +3 -22
  95. package/scripts/test-browser.js +47 -15
  96. package/src/configuration-types.js +1 -1
  97. package/src/database/drivers/base.js +53 -34
  98. package/src/database/drivers/mssql/index.js +32 -0
  99. package/src/database/drivers/mysql/index.js +17 -0
  100. package/src/database/drivers/pgsql/index.js +11 -0
  101. package/src/database/drivers/sqlite/base.js +12 -0
  102. package/src/database/drivers/sqlite/connection-sql-js.js +3 -2
  103. package/src/database/drivers/sqlite/index.js +7 -1
  104. package/src/database/drivers/sqlite/index.native.js +9 -3
  105. package/src/database/drivers/sqlite/index.web.js +6 -3
  106. package/src/environment-handlers/base.js +18 -0
  107. package/src/environment-handlers/node.js +32 -0
  108. package/src/sync/conflict-strategy.js +3 -0
  109. package/src/sync/local-mutation-log.js +24 -0
  110. package/src/sync/sync-api-client-types.js +3 -0
  111. package/src/sync/sync-api-client.js +203 -0
  112. package/src/sync/sync-client-types.js +16 -0
  113. package/src/sync/sync-client.js +198 -14
  114. package/src/sync/sync-envelope-replay-service.js +109 -12
  115. package/src/sync/sync-model-change-feed-service.js +2 -4
  116. package/src/sync/sync-replay-persisted-data.js +50 -0
  117. package/src/testing/shared-transaction-broker.js +48 -7
  118. package/src/testing/shared-transaction-connection-coordinator.js +23 -9
@@ -0,0 +1,489 @@
1
+ // @ts-check
2
+
3
+ import { spawn } from "node:child_process"
4
+ import { execFile } from "node:child_process"
5
+ import fs from "node:fs/promises"
6
+ import { createRequire } from "node:module"
7
+ import path from "node:path"
8
+ import { promisify } from "node:util"
9
+ import timeout from "awaitery/build/timeout.js"
10
+
11
+ /**
12
+ * @typedef {object} BrowserTestChromeRuntime
13
+ * @property {string} browserPath - Chrome executable path.
14
+ * @property {string} browserVersion - Chrome version.
15
+ * @property {string} driverPath - ChromeDriver executable path.
16
+ * @property {string} driverVersion - ChromeDriver version.
17
+ */
18
+
19
+ /**
20
+ * @typedef {object} BrowserTestSystemTest
21
+ * @property {() => Promise<void>} start - Starts SystemTest.
22
+ * @property {() => Promise<void>} stop - Stops SystemTest.
23
+ */
24
+
25
+ /**
26
+ * @typedef {object} BrowserTestChromeDriverService
27
+ * @property {() => Promise<string>} start - Starts ChromeDriver and returns its URL.
28
+ * @property {() => Promise<void>} stop - Stops ChromeDriver and its process group.
29
+ * @property {() => Promise<string>} diagnostics - Reads retained ChromeDriver diagnostics.
30
+ */
31
+
32
+ const require = createRequire(import.meta.url)
33
+ const { binaryPaths } = require("selenium-webdriver/common/seleniumManager.js")
34
+ const { CancellationError, waitForServer } = require("selenium-webdriver/http/util.js")
35
+ const { findFreePort } = require("selenium-webdriver/net/portprober.js")
36
+ const execFileAsync = promisify(execFile)
37
+ const CHROMEDRIVER_START_TIMEOUT_MS = 30000
38
+ const PROCESS_STOP_TIMEOUT_MS = 10000
39
+
40
+ /** @returns {string} - Default runtime manifest path. */
41
+ function defaultManifestPath() {
42
+ return path.join(process.cwd(), "tmp", "browser-test-chrome-runtime.json")
43
+ }
44
+
45
+ /** @returns {string} - Default diagnostic directory. */
46
+ function defaultDiagnosticsDirectory() {
47
+ return path.join(process.cwd(), "tmp", "browser-test-chrome")
48
+ }
49
+
50
+ /**
51
+ * @param {string} executablePath - Executable path.
52
+ * @returns {Promise<string>} - Version command output.
53
+ */
54
+ async function readExecutableVersion(executablePath) {
55
+ const { stdout, stderr } = await execFileAsync(executablePath, ["--version"])
56
+
57
+ return `${stdout}${stderr}`.trim()
58
+ }
59
+
60
+ /**
61
+ * @param {string} versionOutput - Version command output.
62
+ * @param {string} executableName - Executable description.
63
+ * @returns {string} - Parsed four-part version.
64
+ */
65
+ function parseExecutableVersion(versionOutput, executableName) {
66
+ const match = versionOutput.match(/\b(\d+\.\d+\.\d+\.\d+)\b/)
67
+
68
+ if (!match) throw new Error(`Could not parse ${executableName} version from: ${versionOutput}`)
69
+
70
+ return match[1]
71
+ }
72
+
73
+ /**
74
+ * @param {string} executablePath - Executable path.
75
+ * @param {string} description - Executable description.
76
+ * @returns {Promise<void>} - Resolves when executable exists and is executable.
77
+ */
78
+ async function assertExecutable(executablePath, description) {
79
+ if (!path.isAbsolute(executablePath)) {
80
+ throw new Error(`${description} path must be absolute: ${executablePath}`)
81
+ }
82
+
83
+ try {
84
+ await fs.access(executablePath, fs.constants.X_OK)
85
+ } catch (error) {
86
+ throw new Error(`${description} is not executable: ${executablePath}`, {cause: error})
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Resolves, validates, and persists the exact Chrome/ChromeDriver pair.
92
+ * @param {object} [args] - Prewarm options.
93
+ * @param {() => {browserPath?: string, driverPath?: string}} [args.binaryPathsResolver] - Selenium path resolver.
94
+ * @param {string} [args.manifestPath] - Runtime manifest path.
95
+ * @param {(executablePath: string) => Promise<string>} [args.versionReader] - Executable version reader.
96
+ * @returns {Promise<BrowserTestChromeRuntime>} - Prewarmed runtime.
97
+ */
98
+ export async function prewarmBrowserTestChromeRuntime({
99
+ binaryPathsResolver = () => binaryPaths([
100
+ "--browser",
101
+ "chrome",
102
+ "--language-binding",
103
+ "javascript",
104
+ "--output",
105
+ "json",
106
+ "--avoid-browser-download"
107
+ ]),
108
+ manifestPath = defaultManifestPath(),
109
+ versionReader = readExecutableVersion
110
+ } = {}) {
111
+ const { browserPath, driverPath } = binaryPathsResolver()
112
+
113
+ if (!browserPath) throw new Error("Selenium Manager did not resolve a Chrome executable")
114
+ if (!driverPath) throw new Error("Selenium Manager did not resolve a ChromeDriver executable")
115
+
116
+ await assertExecutable(browserPath, "Chrome")
117
+ await assertExecutable(driverPath, "ChromeDriver")
118
+
119
+ let browserVersionOutput
120
+ let driverVersionOutput
121
+
122
+ try {
123
+ browserVersionOutput = await versionReader(browserPath)
124
+ } catch (error) {
125
+ throw new Error(`Chrome could not report its version at ${browserPath}`, {cause: error})
126
+ }
127
+
128
+ try {
129
+ driverVersionOutput = await versionReader(driverPath)
130
+ } catch (error) {
131
+ throw new Error(`ChromeDriver could not report its version at ${driverPath}`, {cause: error})
132
+ }
133
+
134
+ const browserVersion = parseExecutableVersion(browserVersionOutput, "Chrome")
135
+ const driverVersion = parseExecutableVersion(driverVersionOutput, "ChromeDriver")
136
+
137
+ if (browserVersion.split(".")[0] !== driverVersion.split(".")[0]) {
138
+ throw new Error(`Chrome ${browserVersion} is incompatible with ChromeDriver ${driverVersion}`)
139
+ }
140
+
141
+ const runtime = {browserPath, browserVersion, driverPath, driverVersion}
142
+
143
+ await fs.mkdir(path.dirname(manifestPath), {recursive: true})
144
+ await fs.writeFile(manifestPath, `${JSON.stringify(runtime, null, 2)}\n`, "utf8")
145
+
146
+ return runtime
147
+ }
148
+
149
+ /**
150
+ * Loads the exact runtime persisted by prewarming.
151
+ * @param {object} [args] - Load options.
152
+ * @param {string} [args.manifestPath] - Runtime manifest path.
153
+ * @returns {Promise<BrowserTestChromeRuntime>} - Persisted runtime.
154
+ */
155
+ export async function loadBrowserTestChromeRuntime({manifestPath = defaultManifestPath()} = {}) {
156
+ const parsed = JSON.parse(await fs.readFile(manifestPath, "utf8"))
157
+ const browserPath = parsed?.browserPath
158
+ const browserVersion = parsed?.browserVersion
159
+ const driverPath = parsed?.driverPath
160
+ const driverVersion = parsed?.driverVersion
161
+
162
+ if (typeof browserPath !== "string" || typeof browserVersion !== "string" || typeof driverPath !== "string" || typeof driverVersion !== "string") {
163
+ throw new Error(`Invalid browser test Chrome runtime manifest: ${manifestPath}`)
164
+ }
165
+
166
+ await assertExecutable(browserPath, "Chrome")
167
+ await assertExecutable(driverPath, "ChromeDriver")
168
+
169
+ return {browserPath, browserVersion, driverPath, driverVersion}
170
+ }
171
+
172
+ /**
173
+ * Loads a prewarmed runtime, preparing it for direct local browser runs when absent.
174
+ * @returns {Promise<BrowserTestChromeRuntime>} - Browser runtime.
175
+ */
176
+ export async function loadOrPrewarmBrowserTestChromeRuntime() {
177
+ try {
178
+ return await loadBrowserTestChromeRuntime()
179
+ } catch (error) {
180
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
181
+ return await prewarmBrowserTestChromeRuntime()
182
+ }
183
+
184
+ throw error
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Formats a child process exit.
190
+ * @param {{code: number | null, error?: Error, signal: NodeJS.Signals | null}} result - Exit result.
191
+ * @returns {string} - Exit description.
192
+ */
193
+ function formatProcessExit(result) {
194
+ if (result.error) return result.error.message
195
+ if (result.signal) return `signal ${result.signal}`
196
+
197
+ return `exit code ${String(result.code)}`
198
+ }
199
+
200
+ export class ManagedChromeDriverProcess {
201
+ /**
202
+ * @param {BrowserTestChromeRuntime} runtime - Chrome runtime.
203
+ * @param {string} [diagnosticsDirectory] - Diagnostic output directory.
204
+ */
205
+ constructor(runtime, diagnosticsDirectory = defaultDiagnosticsDirectory()) {
206
+ this.runtime = runtime
207
+ this.diagnosticsDirectory = diagnosticsDirectory
208
+ this.driverLogPath = path.join(diagnosticsDirectory, "chromedriver.log")
209
+ this.processOutputPath = path.join(diagnosticsDirectory, "chromedriver-process.log")
210
+ /** @type {import("node:child_process").ChildProcess | undefined} */
211
+ this.child = undefined
212
+ /** @type {Promise<{code: number | null, error?: Error, signal: NodeJS.Signals | null}> | undefined} */
213
+ this.exitResult = undefined
214
+ /** @type {import("node:fs/promises").FileHandle | undefined} */
215
+ this.outputFile = undefined
216
+ }
217
+
218
+ /** @returns {Promise<string>} - ChromeDriver service URL. */
219
+ async start() {
220
+ await fs.mkdir(this.diagnosticsDirectory, {recursive: true})
221
+ await fs.rm(this.driverLogPath, {force: true})
222
+ this.outputFile = await fs.open(this.processOutputPath, "w")
223
+
224
+ const port = await findFreePort("127.0.0.1")
225
+ const child = spawn(this.runtime.driverPath, [
226
+ `--port=${port}`,
227
+ "--verbose",
228
+ `--log-path=${this.driverLogPath}`,
229
+ "--enable-chrome-logs"
230
+ ], {
231
+ detached: process.platform !== "win32",
232
+ stdio: ["ignore", this.outputFile.fd, this.outputFile.fd]
233
+ })
234
+
235
+ this.child = child
236
+ this.exitResult = new Promise((resolve) => {
237
+ child.once("error", (error) => resolve({code: null, error, signal: null}))
238
+ child.once("exit", (code, signal) => resolve({code, signal}))
239
+ })
240
+
241
+ const serviceUrl = `http://127.0.0.1:${port}`
242
+ const earlyExit = this.exitResult.then((result) => {
243
+ throw new Error(`ChromeDriver terminated during startup with ${formatProcessExit(result)}`)
244
+ })
245
+ const exitCancellation = this.exitResult.then(() => undefined)
246
+
247
+ try {
248
+ await Promise.race([
249
+ waitForServer(serviceUrl, CHROMEDRIVER_START_TIMEOUT_MS, exitCancellation),
250
+ earlyExit
251
+ ])
252
+ } catch (error) {
253
+ if (error instanceof CancellationError) await earlyExit
254
+
255
+ throw error
256
+ }
257
+
258
+ return serviceUrl
259
+ }
260
+
261
+ /**
262
+ * @param {NodeJS.Signals} signal - Signal to deliver.
263
+ * @returns {void}
264
+ */
265
+ signalProcessGroup(signal) {
266
+ const child = this.child
267
+
268
+ if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return
269
+
270
+ try {
271
+ if (process.platform === "win32") {
272
+ child.kill(signal)
273
+ } else {
274
+ process.kill(-child.pid, signal)
275
+ }
276
+ } catch (error) {
277
+ if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return
278
+
279
+ throw error
280
+ }
281
+ }
282
+
283
+ /** @returns {Promise<void>} - Stops ChromeDriver and Chrome descendants. */
284
+ async stop() {
285
+ let stopError
286
+
287
+ try {
288
+ if (this.exitResult && this.child?.exitCode === null && this.child.signalCode === null) {
289
+ this.signalProcessGroup("SIGTERM")
290
+
291
+ try {
292
+ await timeout({
293
+ timeout: PROCESS_STOP_TIMEOUT_MS,
294
+ errorMessage: "Timed out waiting for ChromeDriver process group to stop"
295
+ }, async () => await this.exitResult)
296
+ } catch (error) {
297
+ this.signalProcessGroup("SIGKILL")
298
+ await this.exitResult
299
+ stopError = error instanceof Error ? error : new Error(String(error))
300
+ }
301
+ }
302
+ } catch (error) {
303
+ stopError = error instanceof Error ? error : new Error(String(error))
304
+ }
305
+
306
+ try {
307
+ await this.outputFile?.close()
308
+ } catch (error) {
309
+ const outputCloseError = error instanceof Error ? error : new Error(String(error))
310
+ stopError = stopError
311
+ ? new AggregateError([stopError, outputCloseError], "ChromeDriver process and output cleanup failed")
312
+ : outputCloseError
313
+ }
314
+
315
+ this.outputFile = undefined
316
+
317
+ if (stopError) throw stopError
318
+ }
319
+
320
+ /** @returns {Promise<string>} - Retained ChromeDriver diagnostics. */
321
+ async diagnostics() {
322
+ const sections = []
323
+
324
+ for (const [label, filePath] of [
325
+ ["ChromeDriver log", this.driverLogPath],
326
+ ["ChromeDriver process output", this.processOutputPath]
327
+ ]) {
328
+ try {
329
+ const content = await fs.readFile(filePath, "utf8")
330
+ sections.push(`${label} (${filePath}):\n${content || "(empty)"}`)
331
+ } catch (error) {
332
+ const message = error instanceof Error ? error.message : String(error)
333
+ sections.push(`${label} (${filePath}) unavailable: ${message}`)
334
+ }
335
+ }
336
+
337
+ return sections.join("\n")
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Captures Chrome and ChromeDriver processes for startup diagnostics.
343
+ * @param {string} phase - Snapshot phase.
344
+ * @returns {Promise<string>} - Process snapshot.
345
+ */
346
+ async function browserProcessSnapshot(phase) {
347
+ if (process.platform === "win32") return `${phase}: browser process snapshots are unavailable on Windows`
348
+
349
+ try {
350
+ const { stdout } = await execFileAsync("ps", ["-eo", "pid,ppid,pgid,stat,etime,comm,args"])
351
+ const processLines = stdout
352
+ .split("\n")
353
+ .filter((line) => /\s(?:chrome|chromedriver|google-chrome)\s/.test(line))
354
+
355
+ return `${phase}:\n${processLines.length > 0 ? processLines.join("\n") : "(no Chrome/ChromeDriver processes)"}`
356
+ } catch (error) {
357
+ const message = error instanceof Error ? error.message : String(error)
358
+ return `${phase}: process snapshot failed: ${message}`
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Owns ChromeDriver and SystemTest startup as one cleanup boundary.
364
+ */
365
+ export class BrowserTestSession {
366
+ /**
367
+ * @param {object} args - Session options.
368
+ * @param {(runtime: BrowserTestChromeRuntime) => Promise<BrowserTestChromeDriverService>} [args.chromeDriverServiceFactory] - Driver service factory.
369
+ * @param {(phase: string) => Promise<string>} [args.processSnapshot] - Process snapshot callback.
370
+ * @param {BrowserTestChromeRuntime} args.runtime - Chrome runtime.
371
+ * @param {(args: {browserPath: string, remoteUrl: string}) => BrowserTestSystemTest} args.systemTestFactory - SystemTest factory.
372
+ */
373
+ constructor({
374
+ chromeDriverServiceFactory = async (runtime) => new ManagedChromeDriverProcess(runtime),
375
+ processSnapshot = browserProcessSnapshot,
376
+ runtime,
377
+ systemTestFactory
378
+ }) {
379
+ this.chromeDriverServiceFactory = chromeDriverServiceFactory
380
+ this.processSnapshot = processSnapshot
381
+ this.runtime = runtime
382
+ this.systemTestFactory = systemTestFactory
383
+ /** @type {BrowserTestChromeDriverService | undefined} */
384
+ this.chromeDriverService = undefined
385
+ /** @type {BrowserTestSystemTest | undefined} */
386
+ this.systemTest = undefined
387
+ this.remoteUrlWasPresent = false
388
+ /** @type {string | undefined} */
389
+ this.previousRemoteUrl = undefined
390
+ /** @type {string | undefined} */
391
+ this.remoteUrl = undefined
392
+ this.startupPhase = "creating ChromeDriver service"
393
+ this.stopped = false
394
+ }
395
+
396
+ /** @returns {Promise<BrowserTestSystemTest>} - Started SystemTest instance. */
397
+ async start() {
398
+ this.remoteUrlWasPresent = Object.hasOwn(process.env, "SELENIUM_REMOTE_URL")
399
+ this.previousRemoteUrl = process.env.SELENIUM_REMOTE_URL
400
+
401
+ try {
402
+ this.chromeDriverService = await this.chromeDriverServiceFactory(this.runtime)
403
+ this.startupPhase = "starting ChromeDriver service"
404
+ this.remoteUrl = await this.chromeDriverService.start()
405
+ process.env.SELENIUM_REMOTE_URL = this.remoteUrl
406
+ this.systemTest = this.systemTestFactory({browserPath: this.runtime.browserPath, remoteUrl: this.remoteUrl})
407
+ this.startupPhase = "creating Selenium WebDriver session"
408
+ await this.systemTest.start()
409
+
410
+ return this.systemTest
411
+ } catch (error) {
412
+ const startupError = error instanceof Error ? error : new Error(String(error))
413
+ const beforeCleanup = await this.processSnapshot("before cleanup")
414
+ const cleanupErrors = await this.stopResources({driverFirst: true})
415
+ const afterCleanup = await this.processSnapshot("after cleanup")
416
+ const driverDiagnostics = this.chromeDriverService
417
+ ? await this.chromeDriverService.diagnostics()
418
+ : "ChromeDriver service was not created"
419
+ const message = [
420
+ `Browser test startup failed while ${this.startupPhase}: ${startupError.message}`,
421
+ `Chrome: ${this.runtime.browserVersion} (${this.runtime.browserPath})`,
422
+ `ChromeDriver: ${this.runtime.driverVersion} (${this.runtime.driverPath})`,
423
+ `ChromeDriver service: ${this.remoteUrl ?? "not started"}`,
424
+ beforeCleanup,
425
+ afterCleanup,
426
+ driverDiagnostics
427
+ ].join("\n")
428
+ if (cleanupErrors.length > 0) {
429
+ throw new AggregateError([startupError, ...cleanupErrors], message, {cause: error})
430
+ }
431
+
432
+ throw new Error(message, {cause: error})
433
+ }
434
+ }
435
+
436
+ /**
437
+ * @param {object} args - Cleanup options.
438
+ * @param {boolean} args.driverFirst - Stop the driver before SystemTest during failed startup.
439
+ * @returns {Promise<Error[]>} - Cleanup errors.
440
+ */
441
+ async stopResources({driverFirst}) {
442
+ if (this.stopped) return []
443
+
444
+ this.stopped = true
445
+ const cleanupErrors = []
446
+ const stopDriver = async () => {
447
+ try {
448
+ await this.chromeDriverService?.stop()
449
+ } catch (error) {
450
+ cleanupErrors.push(error instanceof Error ? error : new Error(String(error)))
451
+ }
452
+ }
453
+ const stopSystemTest = async () => {
454
+ try {
455
+ await this.systemTest?.stop()
456
+ } catch (error) {
457
+ cleanupErrors.push(error instanceof Error ? error : new Error(String(error)))
458
+ }
459
+ }
460
+
461
+ if (driverFirst) {
462
+ await stopDriver()
463
+ await stopSystemTest()
464
+ } else {
465
+ await stopSystemTest()
466
+ await stopDriver()
467
+ }
468
+
469
+ if (this.remoteUrlWasPresent) {
470
+ process.env.SELENIUM_REMOTE_URL = this.previousRemoteUrl
471
+ } else {
472
+ delete process.env.SELENIUM_REMOTE_URL
473
+ }
474
+
475
+ return cleanupErrors
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Stops SystemTest and the managed ChromeDriver for a browser test session.
481
+ * @param {BrowserTestSession} session - Browser test session.
482
+ * @returns {Promise<void>} - Resolves when all session resources have stopped.
483
+ */
484
+ export async function stopBrowserTestSession(session) {
485
+ const cleanupErrors = await session.stopResources({driverFirst: false})
486
+
487
+ if (cleanupErrors.length === 1) throw cleanupErrors[0]
488
+ if (cleanupErrors.length > 1) throw new AggregateError(cleanupErrors, "Browser test session cleanup failed")
489
+ }
@@ -1,26 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import fs from "node:fs"
4
- import { createRequire } from "node:module"
3
+ import { prewarmBrowserTestChromeRuntime } from "./browser-test-session.js"
5
4
 
6
- const require = createRequire(import.meta.url)
7
- const { binaryPaths } = require("selenium-webdriver/common/seleniumManager.js")
8
- const { browserPath, driverPath } = binaryPaths([
9
- "--browser",
10
- "chrome",
11
- "--language-binding",
12
- "javascript",
13
- "--output",
14
- "json",
15
- "--avoid-browser-download"
16
- ])
5
+ const runtime = await prewarmBrowserTestChromeRuntime()
17
6
 
18
- if (!browserPath || !fs.existsSync(browserPath)) {
19
- throw new Error(`Selenium Manager did not find installed Chrome at ${browserPath || "<missing path>"}`)
20
- }
21
-
22
- if (!driverPath || !fs.existsSync(driverPath)) {
23
- throw new Error(`Selenium Manager did not provision ChromeDriver at ${driverPath || "<missing path>"}`)
24
- }
25
-
26
- console.log(`Prewarmed ChromeDriver at ${driverPath} for Chrome at ${browserPath}`)
7
+ console.log(`Prewarmed ChromeDriver ${runtime.driverVersion} at ${runtime.driverPath} for Chrome ${runtime.browserVersion} at ${runtime.browserPath}`)
@@ -20,6 +20,11 @@ import {normalizeExamplePatterns, parseFilters} from "../src/testing/test-filter
20
20
  import fileExists from "../src/utils/file-exists.js"
21
21
  import dummyDirectory from "../spec/dummy/dummy-directory.js"
22
22
  import {withSourcePeerPackage} from "../src/environment-handlers/node/source-peer-package.js"
23
+ import {
24
+ BrowserTestSession,
25
+ loadOrPrewarmBrowserTestChromeRuntime,
26
+ stopBrowserTestSession
27
+ } from "./browser-test-session.js"
23
28
 
24
29
  const rootDir = process.cwd()
25
30
  const distDir = path.join(rootDir, "dist")
@@ -367,6 +372,8 @@ async function runBrowserTests() {
367
372
  process.env.VELOCIOUS_BROWSER_BACKEND_PORT = String(browserBackendPort)
368
373
  process.env.SYSTEM_TEST_HTTP_PORT = String(systemTestHttpPort)
369
374
 
375
+ const chromeRuntime = await loadOrPrewarmBrowserTestChromeRuntime()
376
+
370
377
  await buildBrowserTestApp()
371
378
 
372
379
  const {testFiles, lineFilters, includeTags, excludeTags, examplePatterns} = await resolveTests(processArgs)
@@ -381,21 +388,30 @@ async function runBrowserTests() {
381
388
  lineFilters,
382
389
  examplePatterns
383
390
  })
384
- /** @type {SystemTest | undefined} */
385
- let systemTest
391
+ /** @type {BrowserTestSession | undefined} */
392
+ let browserTestSession
386
393
  /** @type {Application | undefined} */
387
394
  let backendApplication
395
+ /** @type {Error | undefined} */
396
+ let runError
388
397
 
389
398
  try {
390
399
  const backendConfiguration = await loadBrowserBackendConfiguration()
391
400
  globalThis.__velocious_browser_test_backend_configuration = backendConfiguration
392
401
  backendApplication = await startBrowserBackendServer(backendConfiguration, browserBackendPort)
393
- systemTest = SystemTest.current({
394
- debug: process.env.SYSTEM_TEST_DEBUG === "true",
395
- httpHost: systemTestHttpHost,
396
- httpPort: systemTestHttpPort
402
+ browserTestSession = new BrowserTestSession({
403
+ runtime: chromeRuntime,
404
+ systemTestFactory: ({browserPath}) => SystemTest.current({
405
+ debug: process.env.SYSTEM_TEST_DEBUG === "true",
406
+ driver: {
407
+ type: "selenium",
408
+ options: {chromeBinaryPath: browserPath}
409
+ },
410
+ httpHost: systemTestHttpHost,
411
+ httpPort: systemTestHttpPort
412
+ })
397
413
  })
398
- await systemTest.start()
414
+ await browserTestSession.start()
399
415
 
400
416
  await testRunner.prepare()
401
417
 
@@ -413,10 +429,7 @@ async function runBrowserTests() {
413
429
  if ((hasTagFilters || hasLineFilters || hasExampleFilters) && executedTests === 0) {
414
430
  console.error("\nNo tests matched the provided filters")
415
431
  process.exitCode = 1
416
- return
417
- }
418
-
419
- if (testRunner.isFailed()) {
432
+ } else if (testRunner.isFailed()) {
420
433
  await testRunner.persistFailedTestConsoleOutputsToAssets()
421
434
  const failedTests = testRunner.getFailedTestDetails()
422
435
 
@@ -443,15 +456,34 @@ async function runBrowserTests() {
443
456
  } else {
444
457
  console.log(`\nTest run succeeded with ${testRunner.getSuccessfulTests()} successful tests`)
445
458
  }
446
- } finally {
447
- if (systemTest) {
448
- await systemTest.stop()
459
+ } catch (error) {
460
+ runError = error instanceof Error ? error : new Error(String(error))
461
+ }
462
+
463
+ const cleanupErrors = []
464
+
465
+ if (browserTestSession) {
466
+ try {
467
+ await stopBrowserTestSession(browserTestSession)
468
+ } catch (error) {
469
+ cleanupErrors.push(error instanceof Error ? error : new Error(String(error)))
449
470
  }
471
+ }
450
472
 
451
- if (backendApplication) {
473
+ if (backendApplication) {
474
+ try {
452
475
  await backendApplication.stop()
476
+ } catch (error) {
477
+ cleanupErrors.push(error instanceof Error ? error : new Error(String(error)))
453
478
  }
454
479
  }
480
+
481
+ if (runError && cleanupErrors.length > 0) {
482
+ throw new AggregateError([runError, ...cleanupErrors], "Browser tests and resource cleanup failed", {cause: runError})
483
+ }
484
+ if (runError) throw runError
485
+ if (cleanupErrors.length === 1) throw cleanupErrors[0]
486
+ if (cleanupErrors.length > 1) throw new AggregateError(cleanupErrors, "Browser test resource cleanup failed")
455
487
  }
456
488
 
457
489
  async function main() {
@@ -72,7 +72,7 @@
72
72
  * @property {() => ReturnType<typeof JSON.parse>} [getConnection] - Custom connection factory override.
73
73
  * @property {string} [host] - Database host.
74
74
  * @property {boolean} [migrations] - Whether migrations are enabled for this database.
75
- * @property {boolean} [multipleStatements] - (MySQL) Opt in to multi-statement queries so a whole structure SQL dump loads in one round-trip via `StructureSqlLoader`. Off by default; ordinary queries otherwise reject stacked statements.
75
+ * @property {boolean} [multipleStatements] - (MySQL) Opt in to multi-statement queries so structure SQL loads and all-table cleanup can batch into one round-trip. Off by default; ordinary queries otherwise reject stacked statements.
76
76
  * @property {number} [maxRowsPerInsert] - Maximum rows per `INSERT ... VALUES (...), (...), ...` statement generated by `Record.insertMultiple`. Defaults to 500.
77
77
  * @property {number} [maxInsertSqlBytes] - Maximum serialized SQL size, in bytes, for a single `INSERT ... VALUES (...), (...), ...` statement. Defaults to 1 MiB (1048576).
78
78
  * @property {number} [maxInClauseValues] - Maximum values in a single `IN (...)` cohort used by preloads, association counts, and queryData aggregates. Defaults to 999.