Test harness: integration tier, refactor-protection unit tests, and CI - #599
Merged
Merged
Conversation
test/integration/ boots a real, complete server on an ephemeral port for each test, in about 15ms, and makes real HTTP requests against it. Built on createServer_p from the previous commits. test/support/ holds three pieces: - server.js -- start_p(configText, options) resolving to a handle with .port, .baseUrl, .get_p(), .workerEntries() and .stop_p(). - config.js -- writes a throwaway config into a temp dir, substituting $USER, $ROOT and $DIR. - fake-worker.js -- replaces lib/worker/app-worker's launchWorker_p with one that binds a real http.Server on the endpoint's port. Only `su` and R are stubbed. The real TcpTransport, endpoint, shared secret, connectEndpoint_p handshake and proxy all stay live -- which is the part an Express upgrade actually touches. The seam is a module-export assignment rather than rewire, because rewire loads a second copy of the module and the server built by server-init.js would still use the first. It works because scheduler.js resolves app_worker.launchWorker_p as a property at call time. Note that setTransport() alone is not a sufficient seam: spawnWorker calls posix.getpwnam(appSpec.runAs) and launchWorker_p regardless of transport, which is why test configs must run_as the current user. Two traps are worth recording, because in both cases the symptom points nowhere near the cause: - The harness pins its listener to 127.0.0.1 rather than the wildcard. With `listen 0` on `::`, the kernel can hand the server a port TcpTransport just probed-and-released for a worker; the worker then binds 127.0.0.1 on that same port, which *succeeds* -- a specific-address bind is allowed alongside a wildcard one -- and shadows the server for all loopback traffic. Requests silently reach the worker instead of Shiny Server. - Requests go through http.request with `agent: false`, not fetch(). fetch pools keep-alive sockets per origin; test servers restart milliseconds apart and ephemeral ports get recycled, so a pooled socket belonging to a dead server gets handed to the next test. `Connection: close` is not a fix -- fetch treats it as a forbidden header and drops it silently. Together these took the suite from ~12% flaky to 0 failures in 25 consecutive full runs. mocha is not recursive, so package.json's "test" script and Jenkinsfile both name the directories explicitly; .mocharc.json gains a timeout, since the 2s default cannot survive a server boot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Independent of Express; this is the part meant to make those two refactors
safe. All of it had zero coverage.
- test/qutil.js -- forEachPromise_p (the router chain's control flow), map_p's
sequencing (a naive Promise.all port would change I/O ordering), serialized,
wrap, .eat().
- test/proxy-http.js -- ShinyProxy.httpListener's dispatch contract: the strict
`appSpec === true` check, the 404/500/503 paths, and the acquire/release
accounting including the _.once cleanup on both 'finish' and 'close'.
- test/scheduler-introspection.js -- shutdown() and dump(), the only places
that inspect a promise synchronously (Q's isFulfilled/inspect().value, which
has no native equivalent and so needs a design change during the Q removal).
Pinned as observable outcomes rather than as the Q idiom.
- test/connect-endpoint.js -- the retry ladder and both abort paths, whose
messages end up on the 500 page.
- test/config-{lexer,parser,schema}.js -- the hand-written config language,
which the memory bank rates as the highest-value gap. Ported and expanded
from the manual.test/ scripts nothing ever ran.
Two findings, characterized rather than fixed:
- qutil.serialized hands a *queued* caller the previous invocation's outcome.
Q's .fin() settles with the original promise's value even when its callback
returns a promise, so a queued caller gets whatever ran ahead of it. Benign
today only because the sole production user is loadConfig_p, whose queued
caller is the SIGHUP handler, which .eat()s the result -- but a live trap for
the Q removal.
- connectEndpoint_p's `timeoutId` is assigned null and never set, so the
clearTimeout on the success path is a no-op. What actually stops the retry
loop is the !isPending() guard. Anyone tidying up the unused variable should
know which one is load-bearing.
Also confirms that manual.test/test-config-config.js fails because of a stale
expectation, not a product bug: `run_as;` with no users is legal, because the
schema declares `param String users...`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test/integration-r/ launches actual R processes through the real AppWorker -- the stdin handshake, the port handshake, per-worker logging, teardown. It is not part of `npm test`; run it with `npm run test:r`. It skips itself loudly when R or the shiny package is missing, so a developer without R still gets a useful signal from the rest of the suite; CI treats a skip as a provisioning failure so it can't pass vacuously. These run as the current user: AppWorker only shells out to `su` when appSpec.runAs differs from the process user, so a `run_as $USER` config exercises the real launcher without needing root. They also need an explicit bookmark_state_dir -- otherwise the worker tries to mkdir /var/lib/shiny-server/bookmarks and fails before R is ever started. .github/workflows/ci.yml runs npm ci, the tests and the license check on Linux and macOS, plus a **build-freshness check**: `tsc` followed by `git diff --exit-code lib/`. Generated .js is committed next to the .ts and the tests run against the generated files, so editing a .ts without running `npm run build` currently passes CI while changing nothing at runtime. Node is pinned from .nvmrc: master's nan (^2.18.0) does not compile against Node 24, so anything else fails in node-gyp rather than anywhere informative. The license check runs check-licenses.js and check-upstream.sh directly rather than tools/preflight.sh, which invokes ./bin/node -- a runtime that only exists after the full CMake build. docker/jenkins/Dockerfile.ubuntu-20.04 installs the shiny package; it had r-base only, so the real-R tier would have skipped itself there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Its runner-setup and coverage sections went stale the moment test/integration/ existed. Records the tier layout and the fact that mocha is not recursive (so package.json and Jenkinsfile must list directories in step), how the harness works and why its seam is a module-export swap rather than rewire, the two flakiness traps, and an honest recount of the coverage map -- including that the SockJS and WebSocket paths are now the highest-value remaining gap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…honest The real-R job failed on CI while passing locally. app-worker.ts launches R with a deliberately scrubbed environment -- HOME, LANG and PATH only -- so the worker never sees R_LIBS_USER, which is where r-lib/actions installs by default. `Rscript` in the job could load shiny; the worker could not, and crash-looped one process per request (there is no spawn backoff), leaving five worker logs behind. Install into the site library instead, which R finds unconditionally, and verify it afterwards using `env -i` with the same three variables a worker gets. The Jenkins image already gets this right by accident: its Docker build runs as root, where .libPaths()[1] is the site library. Two changes so this cannot recur silently: - hasShiny() now probes with the worker's environment rather than an inherited one. Before, the skip guard could pass while every test failed with a 500 -- the worst of both worlds, since it neither ran nor announced itself. - The app-page assertion prints the whole response body on failure. It is a rendered 500 carrying the tail of the worker's console log, i.e. R's actual complaint; truncating it made this failure pure archaeology. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.Library.site is NA on setup-r's R -- it ships without a site library -- so the previous attempt resolved to the read-only base library and the install failed outright. R searches R_HOME/site-library unconditionally once it exists, and needs no environment variable to do so, which is what a worker's scrubbed environment requires. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings in #600 so CI validates the harness against the native-addon change rather than the base it was opened on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phases 2–5 of the test-harness plan. Stacked on #598 — review that first;
this PR's base will retarget to
masteronce it lands.Goal isn't "test Express 5". It's to build the characterization net that makes
three pending changes safe — the Express 4→5 upgrade (#596), the Q removal, and
the TypeScript port — and to use Express 4→5 as its first customer.
The tiers
mocha isn't recursive, so every directory is named explicitly (in
package.jsonand
Jenkinsfile— a new directory added in only one silently doesn't run).npm testnpm run test:rshinytest/integration/boots a real, complete server on an ephemeral port foreach test — ~15ms — and makes real HTTP requests. Only
suand R are stubbed;the real
TcpTransport, endpoint, shared secret,connectEndpoint_phandshakeand proxy all stay live, which is the part an Express upgrade actually touches.
Covers
__assets__rewriting (including the?foocase the explicit/?rewrite exists for, and traversal attempts),
send's directory handling and thetext/RMIME mutation,req._parsedUrlsurvival to the proxy, worker refcountcleanup with compression on and off,
X-Powered-By, and the access log'sreliance on
originalUrl.test/integration-r/launches actual R through the realAppWorker. Runs asthe current user, since
AppWorkeronly shells out tosuwhenrunAsdiffersfrom the process user.
Unit tests for what the Q and TypeScript ports will touch, all previously at
zero coverage:
qutil(forEachPromise_pis the router chain's control flow;map_p's sequencing is load-bearing),ShinyProxy.httpListener's dispatchcontract,
Scheduler.shutdown()/dump()(the only synchronous promiseinspection in the codebase — no native equivalent, so it needs a design change
during the Q removal),
connectEndpoint_p, and the hand-written configlanguage, which the memory bank rates as the highest-value gap.
Findings, characterized rather than fixed
qutil.serializedhands a queued caller the previous invocation'soutcome. Q's
.fin()settles with the original promise's value even whenits callback returns a promise. Benign today only because the sole production
user is
loadConfig_p, whose queued caller is the SIGHUP handler, which.eat()s the result — but a live trap for the Q removal.NODE_ENVis never set, so Express runs in development mode and anunhandled throw returns a stack trace, with no 4-arg error handler to stop it.
Asserted, so that adding one becomes a deliberate change. Possibly worth
treating as security-adjacent independently of this work.
connectEndpoint_p'stimeoutIdis assignednulland never set, so theclearTimeouton the success path is a no-op; the!isPending()guard iswhat actually stops the retry loop.
manual.test/test-config-config.jsfails on a stale expectation, not aproduct bug —
run_as;with no users is legal, because the schema declaresparam String users....Two flakiness traps worth reading
Both had symptoms pointing nowhere near the cause; together they took the suite
from ~12% flaky to 0 failures in 25 consecutive full runs.
TcpTransportallocates a worker port bybinding
127.0.0.1:0, reading it, and closing again. A server listening onthe wildcard
::can be handed that same port; the worker then binds127.0.0.1on it, which succeeds — a specific-address bind is allowedalongside a wildcard one — and shadows the server for loopback traffic.
Requests silently reach the worker instead of Shiny Server, surfacing as
inexplicable 404s and
Parse Error: Expected HTTP/. The harness pins itslistener to
127.0.0.1.fetch()pools keep-alive socketsper origin; test servers restart milliseconds apart and ports get recycled, so
a socket belonging to a dead server gets handed to the next test — which then
talks to the previous test's config.
Connection: closeis not a fix: fetchtreats it as a forbidden header and drops it silently. Requests go through
http.requestwithagent: false.CI
.github/workflows/ci.yml—npm ci, tests and license check on Linux andmacOS, plus a build-freshness check (
tscthengit diff --exit-code lib/).Generated
.jsis committed next to the.tsand tests run against thegenerated files, so editing a
.tswithoutnpm run buildpasses CI todaywhile changing nothing at runtime.
Node is pinned from
.nvmrc: master'snan(^2.18.0) does not compile againstNode 24, so anything else fails in node-gyp rather than anywhere informative.
That's also a prerequisite for #596's Node bump. The license check runs
check-licenses.jsandcheck-upstream.shdirectly rather thantools/preflight.sh, which invokes./bin/node— a runtime that only existsafter the full CMake build.
docker/jenkins/Dockerfile.ubuntu-20.04gains theshinypackage; it hadr-baseonly, so the real-R tier would have skipped itself there. CI treats askip as a provisioning failure so it can't pass vacuously.
Verification
npm test: 291 passing, 0 failing, 4.3s (macOS, Node 20.17.0).npm run test:r: 8 passing.tscproduces no diff.so R can't launch fails 6 of 8, and the two that still pass are the ones
correctly independent of R.
Suggested next step
With this in place, #596 can be split into separately-attributable steps —
Node, then
http-proxy-3, then express+send together, then TypeScript — runningthis suite after each, so a status change at the express+send step is a genuine
breaking change rather than something to bisect by hand.
🤖 Generated with Claude Code