Skip to content

fix(test): one cross-platform test runner, green on Linux, macOS and Windows - #837

Merged
cevheri merged 24 commits into
mainfrom
fix/cross-platform-test-runner
Sep 15, 2026
Merged

cevheri merged 24 commits into
mainfrom
fix/cross-platform-test-runner

Conversation

@cevheri

@cevheri cevheri commented Sep 15, 2026

Copy link
Copy Markdown
Member

What this does

bun run test is now one cross-platform runner, tests/run-tests.ts, and the suite it runs is green on Linux, macOS and Windows.

Two defects went with the old arrangement:

  • The documented command was red for everyone. bun run test ran tests/unit, tests/api and tests/integration in ONE bun process, and mock.module() is process-wide with no undo, so a mock one layer needs reached every file after it. On a clean checkout of main that is 12849 pass, 42 fail, exit 1, and the failing set changes with the directory order of the tree. CI never saw it, because CI ran tests/run-core.sh instead.
  • The runners were bash, so they did not run everywhere. tests/run-core.sh used mapfile, a bash 4 builtin; macOS ships bash 3.2, so the gate CI depends on has never worked on a stock Mac. On Windows neither script ran outside Git Bash.

The runner discovers every *.test.ts(x) under tests/ except tests/live/, runs each file in its own bun process, several at a time (one per CPU by default), kills and fails a file that outruns its timeout, and merges per-file coverage into the same coverage/lcov.info the gate and SonarCloud already read. The 46 hand-maintained component groups and tests/unit/component-runner-coverage.test.ts are gone: discovery is a rule, so a new test file cannot be forgotten (which is how WireCompatibilityHint.test.tsx shipped in #426 and never ran once).

Measured

before after
Linux, bun run test 12849 pass, 42 fail, 89 s, exit 1 545 files, 17555 tests, 0 fail, 35 s
Linux without Helm chart tests fail with Executable not found 531 files pass, 12 chart files listed as not run, coverage still 100%
macOS (Cross-platform Tests) the runner could not start (mapfile, bash 3.2) green
Windows from PowerShell (Cross-platform Tests) never measured green
coverage 100%, through two paths (per-file core plus 46 groups) 100% of 56883 lines, one path

Wall clock on a 20-core Linux box: 211 s one file at a time, 61 s at 4 at a time, 36 s at 20. The Windows leg takes about 6.5 minutes and the macOS leg about 3 on GitHub's runners.

Why one process per file, rather than bun test --isolate

--isolate exists in the pinned bun 1.4.2 and does reset the module registry per file, which would let the runner start a handful of processes instead of one per file. It is not adopted because of oven-sh/bun#41655, a NAPI finalizer SIGSEGV that reproduces serially on 1.4.2, and this suite loads three NAPI addons. --parallel was measured too: 0 fail in 7 runs, then one run hung 17 minutes inside a synchronous helm template and one failed 7 tests of docker-bind-address on 5000 ms timeouts. Both refusals, with their evidence, are in docs/TOOLCHAIN.md, and re-probing is filed as D86.

The tests had to change too

The runner alone gets nobody to green. What the platforms actually broke, all fixed here:

  • Paths compared against the host separator, in nine files. One of them, agent-package-boundary.test.ts, would have passed VACUOUSLY on Windows: its predicate matched /(^|\/)(agent|use-agent)/ against a backslash path, so a real boundary violation would have gone unreported.
  • Files deleted while still open (Windows refuses), temp directories written inside the repository, a directory symlink that needs a Windows privilege, git fixtures inheriting the contributor's own commit.gpgsign.
  • Tests that spawned bash, sh, grep, tar or 7z by bare name. On Windows a bare bash is WSL's System32\bash.exe when WSL is installed, a Linux shell that cannot see the Windows path the fixture hands it. tests/helpers/posix-tools.ts now resolves these through the Git for Windows install, located from git itself.
  • Timing races that only a busy machine sees, which is exactly what running several files at once makes normal. SchemaDiagram.test.tsx was red in 11 of 24 concurrent runs; it is 24 of 24 now. A failing expect(<element>).toBeNull() inside a waitFor costs 301 ms, because bun pretty-prints the DOM node it is about to throw away, so 29 poll sites moved to a boolean form.

And three product defects the platforms and the review exposed:

  • SQLiteProvider.disconnect() did not release the file. bun:sqlite's close() leaves a zombie connection while a prepared statement is unfinalized, so the handle went when the collector got there. Measured through /proc/self/fd: after disconnect() resolved, the database, -wal and -shm were all still open. A user cannot delete or move a database Studio has disconnected from.
  • SQLiteProvider.connect() kept the file it had opened when its pragmas then failed, which is what happens when a connection points at a file that is not a database. Same consequence on Windows: the file the user picked by mistake stays undeletable.
  • scripts/operator-catalog-submission.mjs compared import.meta.url to "file://" + argv[1], so on Windows main never ran: the CLI printed nothing and exited 0. It reproduces on Linux whenever the path needs percent-encoding.

What a contributor needs

CONTRIBUTING.md now carries a per-platform prerequisites table: bun 1.4.2, Node 24, Git, and a POSIX shell for the tests that drive packaging scripts (on Windows, the one Git for Windows ships; the tests find it themselves). .env and data/ are not needed.

Helm is not needed either. The twelve chart tests that drive the real helm binary declare // @requires helm on their first line. Where Helm or the chart's built PostgreSQL subchart is missing, the runner does not start them and lists them once, under the reason and the command that fixes it; a selection made only of such files exits 2 rather than going green on nothing. Every CI job that runs the suite sets LIBREDB_REQUIRE_HELM=1, which turns the same condition into an error before anything starts, and helm-pin-matrix.test.ts fails if one of those jobs loses the variable. test-runner-requirements.test.ts holds the marker true of the tree in both directions: a file that spawns helm carries it, and a file that carries it spawns helm.

On Windows, the tests for artifacts that cannot exist there (deb/rpm postinstall, the snap launcher, the AppImage permission audit, the container entrypoint) declare it, with the reason in the test's own title. bun prints a skipped title nowhere, so every child writes bun's junit report and the runner's summary lists each skipped test by title under its file. Nothing is skipped for being merely inconvenient.

CI

The required check Unit & Integration Tests keeps its name and its steps, and still runs bun run test:coverage then bun run coverage:check on ubuntu. A new job, Cross-platform Tests (windows-latest | macos-latest), runs bun run test from each platform's own shell. It is deliberately not a required check yet; once it has a history of being green it is worth promoting, which needs a branch-protection change. Both jobs, and the release validation in npm-publish.yml, set LIBREDB_REQUIRE_HELM=1, so none of them can leave the chart tests out.

npm-publish.yml runs bun run test where it ran bun run test:ci; there is no test:ci any more, because there is no longer a shared-process form of the suite for it to be the alternative to.

Review

GitHub Copilot review could not be requested on this repository (the reviewer request is accepted and comes back empty), so the branch was reviewed by three independent agents, one each for the runner, the honesty of the test changes, and the product and documentation changes, and every finding was then handed to a separate skeptic whose job was to refute it. What survived was fixed here, each with a test: a child exiting 0 without a summary counted as a pass, skip reasons reached no output, --coverage-dir=src would have deleted src/, a red coverage run left a stale merged report for coverage:check to pass, the SQLite connect() leak above, and several stale numbers. One CodeQL finding on the in-process grep translation was also addressed. What the skeptics could not reproduce was dropped.

A later external review added six more, all verified and fixed: the #434 Helm drift guard had silently stopped comparing npm-publish.yml:validate once a third suite site was inserted; that release job also lacked the Node 24 pin its tests need since #737; --merge-into would delete whatever path it was given; skip reasons on a skipped describe did not reach the summary; the SQLite catch nulled the handle outside a finally and no test connected a second time; and the runner's own fixture files were written into the real tests/ tree. The Windows leg then caught one more in the runner itself: with the working directory spelled in 8.3 short form and the runner's own path in long form, a correct relative selector was reported as outside tests/; selectors are now compared as real paths. The branch is rebased onto #812 and #817, and #817's test files were each run 24 times at once under the new runner: 24/24.

A first run on a fresh Windows VM, with only git and an editor installed, is what made Helm optional: installing a Kubernetes tool to see the suite go green was the first thing a contributor who never touches the chart would hit.

A second external review, nine findings, was verified the same way, one agent per finding, each measuring rather than adopting. Two were refuted: the proposed SQLiteProvider.disconnect() fix would have dropped the reference to a handle SQLite reports as still open, which is the file lock this PR set out to remove, and close(true) never threw in any scenario the provider can reach; the timer leak has no effect, because process.exit ends the run and --no-orphans reaps the children. One proposed fix could not work: in a junit classname bun writes the describe separator and a literal > identically. The rest were real, and four were worse than reported. A file that registered no test but printed " 1 pass" was reported PASS with exit 0, so a file's counts now come from its junit report rather than from console text; the skipped-title path comes from the report's nested <testsuite> elements; a link under tests/ is refused by name rather than silently skipped; and SIGTERM, SIGHUP and SIGBREAK now end a run the way SIGINT does, with the scratch directory removed and a removal failure named rather than thrown.

That work was then reviewed in turn, by four agents with separate lenses, one of which mutated each new behaviour in a copy to check that a test really fails without it. It found a regression the round had introduced: draining stdout before exit let a reader that stopped reading swallow a signal, and turned a broken pipe into exit 2, so bun tests/run-tests.ts <file> | head -1 reported that the runner had failed. Both are fixed, with the cases that pin them. It also found a test that asserted more than the runner can promise: a child can lose part of its own console output at its exit under a loaded machine, with no runner in the picture, which is filed as D89 rather than asserted against.

Follow-ups filed

docs/BACKLOG.md loses D68, which this closes, and gains D85 (the hand-copied @/lib/auth mock, which per-file isolation does nothing about), D86 (re-probe --isolate when #41655 closes), D87 (two packaging tests that cannot run on Windows because the scripts they drive shell out to zip and to bash) and B81 (a non-OK seed response the server does not attribute leaves the rail claiming the server serves no seeds, found while fixing the AgentRail race).

Comment thread tests/unit/backlog-structure.test.ts Fixed
@codecov

codecov Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

cevheri added a commit that referenced this pull request Sep 15, 2026
connect()'s catch and connectReadOnly()'s catch closed the handle and then nulled
the reference on the next line. close(true) raises when SQLite cannot close, so a
throw there kept the reference, and connect()'s own `if (this.db) return` would
then answer a retry with a silent success on a provider that is not connected -
the exact no-op the comment says the null prevents. Both now null it in a finally.

The retry was not pinned either: the failed-connect test measured the file being
released but never connected a second time. It does now, and it must reject
again. Mutation check: keeping the reference after the failed connect turns that
second call into a resolve and fails the test.

Raised by an external review of #837.
@cevheri
cevheri force-pushed the fix/cross-platform-test-runner branch from 305b21d to 85f025d Compare September 15, 2026 12:39
cevheri added a commit that referenced this pull request Sep 15, 2026
… job

helm-pin-matrix's #434 guard destructured `const [ci, npm] = SUITE_SITES`. This
branch inserted ci.yml:test-cross-platform between those two sites, so the guard
silently compared ci.yml:test with its own cross-platform twin and stopped
comparing npm-publish.yml:validate with anything. It now compares every suite
site with the first. Mutation check: moving only the release job to another Helm,
in the workflow and in the expected table together, passes the old guard and
fails the new one.

npm-publish.yml's validate job runs the same suite and had no setup-node. The
launcher startup tests that refuse anything below Node 24 arrived with #737 on
2026-09-09, which pinned Node in ci.yml only, and the last release this job
validated ran on 2026-09-08, so the next release would have met them on the
runner image's default Node. It pins Node 24 now, like ci.yml's test job.

Both raised by an external review of #837.
… runner

`bun run test` is now `bun tests/run-tests.ts`: it discovers every test file
under tests/ except tests/live, and runs each one in its own bun process,
several at a time.

Two defects go away with the shell scripts. tests/run-core.sh used mapfile, a
bash 4 builtin, and macOS ships bash 3.2, so the documented gate never ran on a
stock Mac; and `bun run test` itself ran tests/unit, tests/api and
tests/integration in ONE process, where bun's process-wide mock.module() made
the result depend on file order (12849 pass, 42 fail, exit 1 on a clean
checkout of main).

Per file rather than per directory because that is the isolation the suite
needs, not a speed choice: bun 1.4.2 has --isolate, which does reset the module
registry per file, but oven-sh/bun#41655 reports a NAPI finalizer SIGSEGV that
reproduces serially on 1.4.2 and this suite loads better-sqlite3, oracledb and
@duckdb/node-api. The concurrency is what pays for the process boundary:
533 files take about 370 seconds one at a time and about 77 seconds eight at a
time.

Also here, because the runner owns them:
- coverage is per file, merged by scripts/merge-lcov.mjs into the same
  coverage/lcov.info the gate and SonarCloud already read. The 46
  hand-maintained component groups are gone; the two files that must not be
  measured are COVERAGE_EXEMPT_FILES in tests/runner/discover.ts, with the
  measurement that says why.
- merge-lcov.mjs takes --inputs-from=<manifest>, because 533 report paths do
  not fit in a Windows command line, and normalises a backslash SF: path, so a
  report produced on Windows merges as the same file as one produced on Linux.
- scripts/security-check.mjs asks `bun tests/run-tests.ts --list` whether a
  test named by docs/SECURITY.md actually runs, instead of grepping a bash
  script and a hardcoded directory list that had already drifted (it omitted
  tests/evals).
- tests/unit/component-runner-coverage.test.ts, which existed because the
  component runner named its files by hand, is replaced by
  tests/unit/test-runner-discovery.test.ts: the discovery rule must equal what
  is on disk.
- ci.yml gains a non-required Cross-platform Tests job on windows-latest and
  macos-latest, running `bun run test` from each platform's own shell. The
  required check keeps its name and its steps.
…Linux

The runner change alone does not get a contributor to green: the tests
themselves assumed a Linux machine in a dozen ways that never showed up on
ubuntu-latest.

Paths and handles:
- Six files compared a path node returned against a forward-slash literal.
  tests/unit/agent-package-boundary.test.ts was the worst of them: its
  isAgentModule() matched /(^|\/)(agent|use-agent)/ against that string, so on
  Windows it answered false for every module and the boundary gate would have
  passed VACUOUSLY while a real violation went unreported.
- Files that read repository sources through process.cwd() now resolve from
  import.meta.dir, so a test does not depend on who launched it.
- A directory symlink (which needs a privilege a normal Windows shell does not
  have) is now a junction, and the sqlite credential sandbox no longer needs a
  symlink at all.
- Two tests wrote into the working tree (tests-tmp-duckdb/, and the openshift
  chart test vendored the subchart into charts/ and into the contributor's own
  helm config). Both now work in their own temp directory, which is also what
  makes them safe to run beside other files.
- Git fixtures no longer inherit the contributor's global git config, where a
  commit.gpgsign=true makes the fixture commit exit 128.

External tools, through one new helper, tests/helpers/posix-tools.ts:
- A test that spawns "bash" or "sh" by name resolves it explicitly instead:
  on Windows a bare "bash" is C:\Windows\System32\bash.exe when WSL is
  installed, a Linux shell that cannot see the Windows temp path the fixture
  hands it, and Git for Windows keeps the real one off the PowerShell PATH.
  The helper locates it from git itself and refuses the WSL one.
- tests/unit/packaging-standalone-zip.test.ts read the archive with 7z; it now
  reads the central directory in process, so only the packing cases need the
  binary. tests/unit/backlog-structure.test.ts spawned grep about thirty times;
  the same search now runs in process, which also removes the GNU/BSD flag
  divergence.
- Where the artifact cannot exist on Windows at all (a deb postinstall, a snap
  launcher, an AppImage permission audit, a container entrypoint), the test
  says so in its own title and the runner's summary names the file and the
  count. Nothing is skipped for being merely inconvenient.

Docs follow the code: CLAUDE.md, CONTRIBUTING.md (a per-platform prerequisites
table), README.md, docs/TOOLCHAIN.md, seven provider docs, the cut-release
runbook, the curated-issue footer and four source comments whose stated reason
was the shared process. docs/BACKLOG.md loses D68, which this closes, and gains
D85, D86 and D87.
A failing `expect(<happy-dom element>).toBeNull()` inside a waitFor costs 301 ms
on a 260-node subtree, because bun's pretty-printer walks the node's object
graph to build a diff it will throw away. Measured here: capped at 1000 ms, the
toBeNull form gets 9 polls where the boolean form gets 20. Every poll in this
suite succeeds on its first try today, so the cost is invisible - until the
machine is busy, which is exactly what one process per test file makes normal.

All 29 sites now assert the boolean. waitForElementToBeRemoved was measured and
rejected for these: it runs an initial-presence check and throws when the
element is already gone, which it is at all 29.

ConnectionSignature's "does not start the cycle when the viewer asked for
reduced motion" was vacuous: it waited 200 ms for a cycle whose first change
lands at 2600 ms, so it passed with the guard deleted (measured, 1 pass). It now
captures setInterval and carries its own control, and with the guard deleted it
fails.

Fourteen comments pointing at the deleted tests/run-components.sh and its groups
now state what is true: every file has its own process, so a file records what
it cannot share a process with, and nothing has to be registered anywhere.

Also: tests/unit/test-runner-cli.test.ts wrote its throwaway fixture with an
unawaited Bun.write, so the runner sometimes started against an empty file, ran
0 tests and exited 0. It passed on Linux and failed on windows-latest.
…ing two real defects

The suite was run on windows-latest from PowerShell, which is how a contributor
runs it. Ten files failed. Two of them were the tests being wrong about the
platform; two were the product being wrong about it.

Product defects, each with its failing test written first:

- `SQLiteProvider.disconnect()` did not release the file. bun:sqlite's close()
  is sqlite3_close_v2, so with a prepared statement still unfinalized the
  connection becomes a zombie and the OS handle goes only when the collector
  gets there. Measured through /proc/self/fd on Linux: after disconnect()
  resolved and isConnected() was false, the database and its -wal and -shm were
  all still open. POSIX unlinks an open file, so nothing showed; Windows returns
  EBUSY, and a user cannot delete or move a database Studio has disconnected
  from. disconnect() now closes with the throwing form, and the test asserts the
  portable consequence: the WAL sidecars are gone.
- `scripts/operator-catalog-submission.mjs` guarded its entry with
  `import.meta.url === "file://" + process.argv[1]`, a URL compared to a path.
  On Windows main never ran at all: node loaded the module, printed nothing and
  exited 0, so every one of the twenty CLI cases read a silent success. It
  reproduces on Linux too, whenever the path needs percent-encoding (a space is
  enough). It is the only script here not using the repository's own idiom.
- `scripts/lib/pack-standalone-tarball.sh` handed tar an output path it had not
  resolved, and GNU tar reads `C:\...` as host:file and tries to dial it (exit
  2, "Cannot connect to C: resolve failed"). Its zip sibling already resolved
  the path, which is why that one passed.

Tests that were wrong about the platform: a Bun.Glob scan compared
host-separated paths against POSIX-spelled documentation; `node --import <abs
path>` is an ESM specifier, so a drive letter reads as a URL scheme; a POSIX
exec bit does not exist on NTFS, so the flatpark resolver check now asks git for
the committed mode, which is what actually ships; and os.tmpdir() answers the
8.3 short form on that runner, so the DuckDB path comparison uses
realpathSync.native on both sides.

The DuckDB same-process handle test now asserts the real answer on each platform
rather than one platform's: POSIX admits the second handle, Windows refuses it
per handle, and both arms end by proving the file opens once the first handle
lets go. docs/providers/{sqlite,duckdb}.md carry the measurements.
… breaks

One process per test file means several files run at once, so a test that
depended on wall-clock timing is now a flake on any machine that is momentarily
slow. Measured at 24 concurrent runs of the same file on a 20-core box:
SchemaDiagram.test.tsx was red in 11 of 24 runs before this and is 24 of 24
after; object-tree.test.tsx was 4 of 24 red and is 24 of 24.

What each of them was really waiting for:

- SchemaDiagram had 25 fixed sleeps standing in for an un-awaited async chain.
  They are gone: an export waits for the button to go disabled (the control that
  proves the export started) and then to come back, which is co-extensive with
  the chain including its error path. File time 4.82 s to 869 ms.
- object-tree's expandApp() returned on the folder row, which is drawn from the
  declared kind and is on screen a round trip before the counts answer, so every
  caller that read a badge was racing. It now returns on the badge. The
  "refresh clears the failure" test waited on a row count that was already
  satisfied before the action under test ran, so it waited for nothing; it now
  waits on the failure clearing and asserts the refusal was replaced by the
  engine's answer.
- first-paint waited on a row rendered one request before the request it counts.
- TablesTab's next click was delivered to a disabled button, so it silently
  never happened; the test now waits for the button to come back.
- QuerySafetyDialog asserted an absolute CPU budget (200 ms) for a predicate
  whose defect is quadratic growth. It now asserts the growth ratio between
  35 KB and 350 KB inputs, which is what discriminates: 10.3x idle, 17.8x at 40
  concurrent processes, 25.1x at 64, against about 97x for the pattern it
  replaced.
- use-storage-sync asserted an elapsed-time threshold; it now asserts an
  ordering between two timers, which is true however slow the machine is, and
  the ordering is proved to discriminate by mutation.
- factory.test.ts closes its providers before deleting their directory, which is
  what Windows needs, and two /tmp literals became tmpdir().
- The two provider censuses import their shared connection from
  tests/helpers/census-connection.ts instead of one test file importing the
  other, which was registering the census suite twice: 15 tests became 6.
538 files and 17,376 tests, counted with the runner itself, and the wall clock
at four concurrency levels on a 20-core Linux box: 211 s one file at a time,
61 s at 4, 36 s at 20. Also replaces the em dashes two provider-doc paragraphs
picked up.
…an answer

All three were caught by running the suite the way contributors now will: many
files at once, on three operating systems.

- AgentRail's seed fixture waited for `servedSeeds` to be "defined", and the
  hook's initial value is `NO_SERVED_SEEDS`, so the wait was satisfied on the
  first render and the test read the state before the request was answered.
  Measured: 6 of 24 concurrent runs red, and a probe showed 19 of 24 reading the
  module constant by identity. The wait is now on the state no longer BEING that
  constant, which is the only thing that separates "the server answered with no
  seeds" from "nobody has answered yet", since the two are deep-equal. 96
  reproduction runs green after the fix. The hook defect this exposed, a non-OK
  the server does not attribute to its seed configuration leaving the state at
  "serves no seeds", is filed as B81 with the sentence a user is shown.
- factory.test.ts built its unresolved path spelling with `split("/")`, which on
  Windows returns the whole backslash path, so the spelling was nonsense and
  matched nothing. It uses basename now.
- The SQLite release test asserted the WAL sidecars were gone. Probed on all
  three runners: that is true on Linux and Windows and false on macOS, where
  bun:sqlite links Apple's system libsqlite3, which keeps them after a real
  close (a second library's close then removes them, which needs the exclusive
  lock a surviving handle would deny). The test now asks each platform what it
  can answer: everywhere, the file can be renamed, which is what Windows refuses
  for a live handle; on Linux, no descriptor of this process still points into
  the directory, which is the measurement the defect was found with. Mutation
  check: restoring the deferred close fails it again.
CodeQL flagged the pattern escape as incomplete because it escapes the BRE
metacharacters but not the escape character. The refusal above it means a
backslash never reaches that line, so the finding is not reachable today, but a
translation that is only correct while its caller is should not be left that
way: the backslash joins the class. ` . * ^ $` stay unescaped on purpose, since
BRE and JS read them alike.
connect() opens the handle before its pragmas run, so a connection pointed at a
file that is not a SQLite database - the ordinary wrong-file mistake - opened it,
failed on "file is not a database", and kept it. Measured through /proc/self/fd
before the fix: one descriptor on the file survived a connect() that had already
thrown and reported isConnected() false. On Windows the user then cannot delete
or move the file they picked by accident, the class of defect disconnect() was
just fixed for. Nulling the handle also stops connect()'s own `if (this.db)
return` from turning a retry into a silent no-op.

Found by peer review of this branch. docs/providers/sqlite.md also stops calling
the WAL sidecars the portable proof of a release: probed on all three runners,
Apple's system libsqlite3 keeps them after a real close.
Each of these was reproduced by a reviewer before it was fixed.

- A child that exits 0 WITHOUT printing bun's summary was reported PASS and the
  run exited 0. A test calling process.exit(0) does exactly that: bun prints its
  banner, exits 0, and every test after that line never runs. It is now a failure
  whose reason says its tests are unaccounted for, and so is a file that
  registers no test at all.
- Skip reasons reached no output. bun prints a skipped test's title nowhere
  (measured piped, with FORCE_COLOR and under a real pty), and the runner printed
  a passing child's output nowhere either, so on Windows, where a dozen files
  declare skips, the run said "0 fail" and named nothing. Every child now writes
  bun's junit report, and the summary lists each skipped test by title under its
  file. Two comments that claimed bun printed those titles in a terminal are
  corrected.
- --coverage-dir is emptied before a coverage run, with no check on what it
  named: --coverage-dir=src deleted the product. The runner now refuses a
  directory holding anything a previous coverage run did not write, and names
  what it found.
- A red coverage run left the previous coverage/lcov.info in place, which
  coverage:check would then pass. The merged report is removed before the run.
- A timed-out file was reported against its elapsed time (budget plus kill
  escalation) rather than its budget, and a child that finished as the timer
  fired was counted as timed out.
- The runner's own fixture test file could survive an interrupted run and turn
  every later run red; a leftover is now removed when the file loads.
- A relative selector is resolved from the working directory, as the person
  typing it meant.
- The QuerySafetyDialog growth-ratio guard gains an absolute ceiling, so a
  uniformly slow rewrite cannot hide behind a linear shape.
79 browser tests in 18 spec files for `bun run test:e2e` (plus the base-path
spec on its own config), not 32; 539 files and 17,396 tests; 100% of 56,671
lines. The four localized READMEs said six test layers where the English one now
says seven, which readme:check cannot see.
Measured on windows-latest with the suite running four files at once: "a
reachable remote pin is compared like a local one" took 4480 ms and reported the
local server's pin as unreadable. The test had set a 3000 ms fetch budget for the
node process it spawns, and bun's own per-test default is 5000 ms, which is sized
for a function call rather than a process start plus an HTTP round trip.

The fetch budget is removed rather than raised. No case in these two describes
needs a fetch to time out: every failure they exercise answers at once, with a
refused connection or a status code, so the short budget could only ever fail the
success path. The script's default applies, which is what a real run gets. The 18
cases that spawn node and fetch get a named 30 s test budget; the pure case in the
same describe keeps bun's default.
connect()'s catch and connectReadOnly()'s catch closed the handle and then nulled
the reference on the next line. close(true) raises when SQLite cannot close, so a
throw there kept the reference, and connect()'s own `if (this.db) return` would
then answer a retry with a silent success on a provider that is not connected -
the exact no-op the comment says the null prevents. Both now null it in a finally.

The retry was not pinned either: the failed-connect test measured the file being
released but never connected a second time. It does now, and it must reject
again. Mutation check: keeping the reference after the failed connect turns that
second call into a resolve and fails the test.

Raised by an external review of #837.
… job

helm-pin-matrix's #434 guard destructured `const [ci, npm] = SUITE_SITES`. This
branch inserted ci.yml:test-cross-platform between those two sites, so the guard
silently compared ci.yml:test with its own cross-platform twin and stopped
comparing npm-publish.yml:validate with anything. It now compares every suite
site with the first. Mutation check: moving only the release job to another Helm,
in the workflow and in the expected table together, passes the old guard and
fails the new one.

npm-publish.yml's validate job runs the same suite and had no setup-node. The
launcher startup tests that refuse anything below Node 24 arrived with #737 on
2026-09-09, which pinned Node in ci.yml only, and the last release this job
validated ran on 2026-09-08, so the next release would have met them on the
runner image's default Node. It pins Node 24 now, like ci.yml's test job.

Both raised by an external review of #837.
- --merge-into is removed before a coverage run so a red run cannot leave a stale
  report behind, and like --coverage-dir it is a path the caller chooses:
  --merge-into=package.json deleted package.json. An existing target must now be
  an lcov report (it starts TN: or SF:, or it is empty), and anything else stops
  the run and says what it found. Probed end to end: exit 2, package.json intact.
- A test skipped by describe.skip carries its reason in the DESCRIBE title, and
  the summary printed only the inner test's name, so the Windows packaging skips
  listed what they check and not why they did not run. bun writes the describe
  path in junit's classname, innermost first (measured 1.4.2); the summary now
  prints the path outermost first, then the name. Pinned end to end.
- Ctrl+C left the run's junit scratch directory behind. It is removed on every
  exit now; probed with a control that sees it exist mid-run and gone after.
- The runner's own failing and empty fixture files were written into this
  repository's tests/unit/, where an interrupted run left a red file and every
  concurrent walker of tests/ could see it. Those cases now run a copy of the
  runner in a temporary directory with a tests/ tree of its own.
- tests/unit/lib/auth-bootstrap.test.ts still ended two tests with a bare
  `return` on Windows, which reports a pass for an assertion that never ran. They
  are named skips now, the way instrumentation.test.ts already does it.
- loop/scripts/gate.sh ran `bun run test` and then `bun run test:coverage`, which
  is the same runner over the same files twice. It runs the coverage form once.

Not taken, with the reason: the QuerySafetyDialog ratio cannot divide by zero in
practice (10000 consecutive performance.now() calls gave 10000 distinct values,
and the small input costs about 1.7 ms), and a zero would fail the assertion
loudly rather than pass it. Making the cross-platform job a required check is a
branch-protection decision for the owner.

Counts follow the rebase onto #812: 540 files, 17,435 tests, 100% of 56,764
lines.
…one directory agree

Measured on windows-latest: the three sandbox cases added in the previous commit
exited 2. os.tmpdir() there answers the 8.3 short form (C:\Users\RUNNER~1\...)
while import.meta.dir answers the long one (C:\Users\runneradmin\...), and since
relative selectors are now resolved from the working directory, path.relative
compared the two spellings and called a correct selector "not under tests/".

That is the runner being wrong, not the test: a contributor whose checkout is
reached through a junction, a subst drive or a symlink would hit the same
sentence. Root and working directory are now compared as real paths. A junction
reproduces it on Linux, which is how the new discovery test pins it (it failed
with exactly the Windows message before the fix). The sandbox cases also carry
the runner's stderr in their exit-code assertion, so a red one says why.
542 files and 17,477 tests, 100% of 56,883 lines. #817's test files were also run
24 times at once each, under the per-file runner they have not met before: 24/24.
@cevheri
cevheri force-pushed the fix/cross-platform-test-runner branch from 5e4aead to 3eab7a2 Compare September 15, 2026 13:08
…ire them in CI

A fresh Windows VM with only git and an editor could not get a green bun run test without installing Helm and building the chart's PostgreSQL subchart, a Kubernetes tool most contributors never touch.

The twelve test files that spawn helm now declare // @requires helm on their first line. Where helm is not on PATH or the subchart is not built, the runner does not start them, and its summary lists them once under the reason and the command that fixes it. A selection made only of such files exits 2 rather than going green on nothing.

Every CI job that runs the suite sets LIBREDB_REQUIRE_HELM=1, which turns the same condition into an error before anything starts. helm-pin-matrix fails if a job loses the variable, and test-runner-requirements holds the marker true of the tree in both directions.

Measured on Linux with helm hidden from PATH: 531 files pass and coverage is still 100% of 56883 lines. With the variable set: 543 files, 17499 tests, all gates green.
Each worker runs one file at a time and the concurrency comes from the number of workers, so no-await-in-loop on that line is the design rather than a missed Promise.all.
…real-path space

Discovery classified each entry by its Dirent type, and a Dirent for a symbolic link is neither a file nor a directory, so a linked test directory was skipped without a word and naming the link said it matched no test files. Entries are classified with lstat now, which also reports a Windows junction as a link, and a link is refused by name rather than followed: following one can run files from outside the repository, loop on a link to a parent, and list one file twice under two names.

A relative selector that passed through a link was refused as not under tests/, although the same path given absolutely worked. Every selector is resolved against the working directory and then realpathed when it exists.
… the way its docs say

The counts came from bun's console output, which is free-form text mixed with whatever the tests printed. Measured on 1.4.2: a file that registered no test and printed " 1 pass" was reported PASS with exit 0, and so was a test that printed a whole summary on stderr and then called process.exit(0), which defeats both guards that keep a red tree from going green. The counts come from the junit report each child already writes, a missing or unreadable report fails the file and says which, and a report that records a failure fails it whatever the exit code says.

The skipped titles come from the report's nested testsuite elements: in classname bun writes the separator and a literal ">" identically, so a describe titled "rows where count > 100" printed as "100 > rows where count".

Each child's output is kept head and tail, a megabyte each end, rather than whole, and a passing file's output is dropped once it has been reported. Every exit path lets its last lines drain first: with stdout a pipe, a failing file that printed 200 KB lost its output and the whole summary. A reader that goes away keeps the run's own exit code, so "| head -1" no longer answers 2.

SIGTERM, SIGHUP and SIGBREAK stop a run as SIGINT does, with 128 plus the signal's number, and a scratch directory that cannot be removed is named rather than thrown: a throw inside the listener left bun running and the queue starting the next file. A signal is answered even when the reader has stopped reading, and a second one kills.
Says where a file's counts come from and why the console is not read, which signals end a run and with what code, what the output capture keeps, and what draining covers: a child can still drop its own queued console output at exit, which no drain in the runner can put back.

Files D88 for a memory-aware default concurrency, D89 for that child-side loss, and D90 for a committed .only, which bun honours so the file reports PASS with its other tests never run. Re-counts the README tables and dates every measurement that is not current.
…st-runner

main's #839 filed D91 to D94, so this branch's three runner entries move to D95, D96 and D97, and the sentences that cite them move with them. Its four new test files bring the tree to 549, which the README, docs/TOOLCHAIN.md and D86 now say.
@gitguardian

gitguardian Bot commented Sep 15, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
36754982 Triggered Generic Password 1c9dacf tests/isolated/factory.test.ts View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

…e same sites

Both tests build a repository-relative path with path.relative and compare it to a POSIX literal, so on windows-latest the keydown registration sites and the wire module's import closure came back spelled with backslashes and both files were red. That is the defect class this branch exists to remove, so both normalise the separator the way the rest of the suite does.
@sonarqubecloud

Copy link
Copy Markdown

@cevheri cevheri self-assigned this Sep 15, 2026
@cevheri
cevheri merged commit 2703ddc into main Sep 15, 2026
28 of 29 checks passed
@cevheri
cevheri deleted the fix/cross-platform-test-runner branch September 15, 2026 18:24
Asgabani added a commit to Asgabani/libredb-studio that referenced this pull request Sep 15, 2026
Rebased onto upstream/main past libredb#837 (the only conflict was
tests/run-components.sh, deleted upstream in favor of the new
auto-discovering test runner - my one-line registration of
ShortcutsDialog.test.tsx there is now moot).

Three fixes from the maintainer's second review:

1. One Escape closed the shortcuts dialog AND the profiler underneath
   it. Radix's Dialog handles Escape in the capture phase and only
   calls preventDefault(), never stopPropagation(), so DataProfiler's
   own bubble-phase Escape listener still fired. Added a
   defaultPrevented check.

2. SHORTCUT_GROUPS was a second hand-maintained list with no guard
   tying it to SHORTCUTS, so a fifth registry entry could go
   undocumented in the dialog. Added a generic test that every
   SHORTCUTS entry's label appears somewhere in SHORTCUT_GROUPS.

3. The maintainer corrected my "nothing there yet" answer on tab-strip
   vs. object-tree keys: ObjectTree.tsx does implement a W3C tree
   keyboard pattern on its role="tree" root (arrows, Home/End,
   Enter/Space, Shift+F10), just not in TreeRow.tsx where I'd looked.
   Added an "Object tree" group documenting it, display-only like the
   tab-strip arrows (none of it is a Monaco command).

Also updated the three places that manually enumerate every
document-level keydown listener for D82 reasons (Studio.tsx,
StudioWorkspace.tsx, tests/unit/document-keydown-listeners.test.ts,
tests/components/studio/embedded-source.test.tsx) now that
ShortcutsDialog.tsx's `?` listener makes it five instead of four.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants