Where cross-workspace test material lives
Status: decided and built. Alcove first decided to add no dedicated home, naming the conditions that would reopen the question; one of them then fired, and the home exists as packages/testkit -- private, unpublished, consumed as raw TypeScript -- holding what @alcove/core/testing cannot. This note records the constraints that shaped both decisions, what fired the trigger and what the second decision was measured against, what each piece of test-only material inside packages/core/src is, and the alternatives weighed and not adopted. See docs/notes/README.md.
This is design rationale. Nothing here binds an implementation; the operational rule a contributor follows is the one in TESTING.md, and this note does not restate it.
The question
A helper that only tests use, and that more than one workspace's test tree needs, has exactly one channel: @alcove/core/testing, a subpath export of the published @alcove/core package. That channel has two properties.
- It cannot take a dependency
packages/coredoes not declare in its owndependencies. A root devDependency is out of reach from it. - Everything it holds is built into
dist/testing.*and published, because core ships its wholedist.
So a helper two workspaces need either moves into packages/core/src and ships with the package, or -- if it needs a root devDependency -- cannot use the channel at all. The question is whether Alcove builds a home with neither property, and if not, what would make it build one.
The first decision, and what it rested on
Build no new home. @alcove/core/testing remains the single cross-workspace channel for test-only material, test-only material shipping in dist/testing.* is accepted as the steady state, and nothing moves. The reopen condition is a line in TESTING.md rather than a promise here, because the failure mode this decision guards against is the whole question being retaken from scratch by whoever next hits one of the constraints below.
That decision turned on there being no inhabitant: every approach was measured working, and what none of them had was a helper that needed it.
The second decision: the home exists
An inhabitant arrived -- the WebRTC inbound frame fixture set, which the CLI's reassembler and the web app's PeerJS wrap must both be held to, and which is built with the real peerjs-js-binarypack packer. It fires two of the reopen conditions at once: it is a third piece of test-only material that would move into packages/core/src purely for reach, and it needs a dependency packages/core does not declare. So packages/testkit was built, in the minimal form alternative C describes: private, no build script, no dist, nothing published, an exports map onto ./src/*.ts, and one explicit subpath per subject.
Two costs the first decision anticipated turned out to be already paid or smaller than recorded. The root build script is npm run build --workspaces --if-present, so a workspace declaring no build script no longer fails it. And adding the workspace touched no tsconfig, no rollup config, no eslint config, and no vitest config: package-lock.json gains the two entries npm writes for a workspace link, and each consuming app declares the dependency.
The junk-drawer risk the first decision named is answered by the admission rule in TESTING.md rather than by the package's existence: material enters only when a second workspace's test tree needs it AND it cannot take the @alcove/core/testing channel. Nothing migrated -- the existing subjects of that channel meet its condition and stay on it.
What constraint 1 does when the dependency is bundleable
The typescript case fails the core build loudly. peerjs-js-binarypack does not: measured on this repository, an import of it from packages/core/src/testing.ts builds green and inlines a copy of the packer into the published dist/testing.esm.js (119,902 bytes to 130,260, with no import of the package left in the output). That is the same constraint operating silently instead of loudly, and it is worse in one respect -- the repository exact-pins that packer because the wire encoding it produces is a compatibility surface, and vendoring a second copy of it into a published bundle puts that pin one build away from meaning nothing. A helper needing a dependency core does not declare therefore takes the testkit whether or not the build would tolerate inlining it.
Later (2026-10-15): the reason given for the testkit above, a dependency packages/core does not declare, has lapsed. Core declares peerjs-js-binarypack, because the PeerJS framing moved into it and unpacks the wire with it, and core's rollup config keeps every declared dependency external, so a frame set in dist/testing.* does not inline a copy of the codec. The set therefore meets the @alcove/core/testing condition and takes that channel (packages/core/src/connection/webrtcInboundFrameFixtures.ts), under the admission rule in docs/TESTING.md, Shared test material, and the testkit declares no packer.
The constraints, and where they bite
Each is a property of configuration this repository already has, so each is re-derivable rather than taken on trust.
- Rollup externals.
makeExternalinpackages/core/rollup.config.tsbuilds the external set frompackages/core/package.json'sdependenciesalone.typescriptis a root devDependency and is in no workspace'sdependencies, so an import of it fromsrc/testing.tsmakes rollup inline the compiler, and the CJS interop fails the core build outright ("default" is not exported by .../typescript/lib/typescript.js). The constraint is the dependency, not the file's location: moving the helper elsewhere insidepackages/coredoes not lift it. A loud failure is the lucky case -- a dependency rollup can bundle is inlined into the published output instead, which is what the second decision measured. - The project-reference redirect.
apps/web/tsconfig.jsonreferencespackages/coreas a composite project, so a web file importing a.tsthat belongs to core's project is redirected to a per-file declaration output the rollup build never emits -- TS6305. It fires only for a consumer OUTSIDEpackages/core, which is why a helper shared between two of core's own tests never meets it. - The include list.
packages/core/tsconfig.jsoniscomposite: trueoversrc/**/*.tsandtest/**/*.ts, so a core file importing a.tsoutside that list fails with TS6307.
Constraints 2 and 3 are exhaustive over locations inside the repository's own project graph: a file is either in core's file list, and trapped by 2 for a consumer outside core, or outside it, and trapped by 3 for core itself. A file resolved through node_modules is in neither -- TS6307 does not reach a node_modules resolution, and no composite redirect exists for a package that publishes no declarations. That exemption is what every candidate home below turns on.
What the first decision rested on
The case that raised the question resolved without a channel. Two compiler-API walks over declared type positions -- one behind the linkage-term consent classification, one behind the invitation summary's brand types -- sit in packages/core/test and share one helper, packages/core/test/utils/declaredPositions.ts. That helper imports typescript freely: it is inside core's file list, and no consumer outside core imports it, so constraints 1 and 2 do not apply to it. The demand that would have paid for a cross-workspace home was satisfied by a same-workspace refactor.
No other duplication is pulling. apps/cli/test and apps/web/test share no helper between them. Every piece of test material that crosses a workspace boundary crosses it through @alcove/core/testing, which serves its consumers today -- the CLI's unit and integration suites and the web browser suite.
The approaches that lift the constraints change shared build configuration. Dropping web's project reference to core, and splitting core's tsconfig so its tests leave the composite declaration graph, both alter how a workspace typechecks against core repository-wide. Changing that to deduplicate test scaffolding, with no scaffolding currently waiting to be deduplicated, buys nothing and risks a build surface every workspace depends on.
The approach that changes no shared build configuration would be built empty. A test-only workspace consumed as raw .ts works -- see the alternatives below -- but no helper exists today that needs it, and the existing cross-workspace subjects reach their consumers through a channel that serves them. A package defined by where it may be imported from rather than by what it holds accretes, and the moment to draw its bright line is the moment it has a first inhabitant -- which is also the moment the line can be drawn against a real example rather than an imagined one.
The accepted cost is unmeasured, not absent. Test-only material published to consumers in dist/testing.* is a real cost: it enlarges the package surface and puts fixtures in front of anyone reading core's exports. Nothing measures it -- there is no package-size budget and no consumer has raised it -- so it is accepted rather than priced, and its arrival on the trigger list below is what turns it back into a live question.
The second decision overtook exactly two of these: the two apps' test trees acquired material they both need, and the approach that would have been built empty acquired its inhabitant. The rest still hold, which is why the home is narrow rather than a general test-scaffolding package.
The test-only material inside packages/core/src
Nothing moved when the home was built, and the admission rule is why: each of these can take the @alcove/core/testing channel, so the fact that a second channel now exists is not a reason to migrate it. What would move one is a decision about core's export map, or the shipped dist/testing.* surface being priced -- not this note.
linkageTermConsentCoverage.ts-- test-only. A classification of every linkage term by whether it bears on consent, plus the probes that hold the consent summary to it. Coupled to core's own types, consumed by core's tests and by the web browser suite. It imports nothing core does not declare, so it stays on the channel that serves it, and it ships.displayEscapingFixtures.ts-- test-only. Hostile-input fixtures (control characters, bidirectional overrides, confusable donors) shared by core's tests and the web browser suite. Same handling, and the clearer shape of the cost: a fixture wanted on two surfaces reaches them by living in a published package.signingIdentityDocuments.ts-- test-only. Signing-identity documents and the party name a certificate-only load owes each, driving the CLI'sloadSigningCertificateand the console'sreadBoundIdentitybound-identity parity tests. Same handling: a case wanted on both loaders reaches them by living in a published package.webrtcInboundFrameFixtures.ts-- test-only. The labelled WebRTC inbound frames, chunk envelopes and malformed datagrams both transports' parity suites drive, built with the packer core declares.websocketRelayProofFixtures.ts-- test-only. The labelled refused WebSocket relay proofs and the refusal each draws, driven through core's proof parse and check and through the relay's hello. It imports only core's own modules, so it takes this channel and ships.- The helpers
testing.tsdefines --sortAssociationTable,withCapturedLogs,installCapturedLogsInterceptor, and theLogEntryshape -- test-only. No product code calls them. computeKexKeys, re-exported fromkex.ts-- NOT test-only, and it does not move whatever is decided later. It is product coderunKexcalls; only its exposure is test-scoped, so that the browser cross-implementation suite can drive the checked-in known-answer vectors through the browser build. A test home is the wrong place for it: what it needs is a decision about core's export map, not about where tests keep their fixtures.
What the export-map decision did to this channel
The open question below named one event that would move a subject off this channel or onto it: a decision about core's export map that has to rule on whether ./testing stays a public subpath. That decision has since been taken. Core's main entry was a barrel of export * lines, so what the package published was whatever its modules happened to export; the barrel is now a named list, and ./testing stays a public subpath beside it.
The ruling moved names in both directions. Core's own known-answer vector generators are plain Node scripts, so they read the built package rather than the source tree beside them, and the wire pieces they compose their documents from -- the index-table and single-pass reply codecs, the terms envelope, the standardized key iterable -- were reachable only because the barrel published everything. A caller runs an exchange rather than composing a table encoding, so none of them belongs on the main entry; they are on ./testing, which states what they are. withSuppressedLogs went the other way: nothing called it, so it is deleted rather than published.
reconcileHostKeyFingerprints is on the channel for the same reason computeKexKeys is, and is the second inhabitant of that class: product code whose only consumers outside packages/core are the two apps' test trees, each holding the divergence warning it composes to that app's own display budget, while the exchange itself calls it from runExchange and hands the caller the result.
A third class followed: the constants only a test reads. The file-sync envelope's framing bytes and the AEAD envelope's version marker, the terminal frame's drain timeout, the invitation and CSV input bounds, and the two display markers are each enforced or written by core itself, and what a caller does is send a message, decode an invitation, or read rendered text. They moved to ./testing, where each app's suite builds a frame or drives a bound at its edge, and the capabilities their neighbours on the main entry publish -- the codecs, the parsers, the display renderers -- stayed.
createMessagePipe reached the same channel from a different starting point. It sits in a product module, connection/messageConnection.ts, but nothing in production builds one: outside packages/core its only callers are the two apps' test trees, and what a caller does is open a connection over a transport. It is on ./testing beside the codecs.
The main entry admits a name only when code outside packages/core names it; a name only the apps' test trees name is published on ./testing instead -- refusal classes a suite matches by instanceof, record and receipt builders, the PSI participant, the standardized dataset. withNoListedFanOutFunctions, the lever that stands a listed fan-out producer in for an unlisted one, is on neither entry: only core's own tests call it, and they import it from source. The published entry points build together with a shared chunk, since a refusal class one entry publishes has to be the class another entry's code throws.
Alternatives weighed
- Drop web's project reference to core. One line out of
apps/web/tsconfig.jsonlifts constraint 2, and the repository typechecks green without it. Cost: it changes how the web app typechecks against core everywhere, permanently, to serve test scaffolding -- and it leaves constraint 1 standing, so a shared helper needingtypescriptis no closer. - A test-only workspace consumed as raw
.ts. ADOPTED, aspackages/testkit. Apackages/*package that is private, has no build and nodist, and whoseexportsmap points at./src/*.tsis resolved throughnode_modulesand therefore exempt from constraints 2 and 3. Core, the CLI test config, and web all typecheck a consumer of one, its source is inside each consumer's program rather than skipped, and it may import both a dependency core does not declare and@alcove/coretypes at once -- no project-reference cycle arises, because no project reference exists in either direction. Its standing costs: each consumer typechecks the shared source inside its own program, so an error there is reported once per consuming project rather than once globally, and it is a top-level package with an admission rule to hold. The cost the first decision recorded against it -- the rootbuildscript failing on a workspace that declares nobuildscript -- was already paid, that script being--if-present. - Split core's tsconfig. Take
test/**/*.tsout ofpackages/core/tsconfig.json'sincludeand add a non-compositetsconfig.test.jsoncovering source and tests, the shapeapps/clialready runs. Costs the roottypecheckscript a fourth invocation -- without it core's tests silently stop being typechecked in CI, so that edit is part of the change rather than an extra -- and moves core's tests out of the composite declaration graph. - A hand-written
.d.tsbeside a plain.js, outside core's include. Exempt from every constraint at zero configuration cost, and rejected on its merits: it buys an untypechecked implementation behind a hand-maintained declaration, for material whose whole value is the precision of its failure mode.
Escapes that do not work
Recorded so they are not re-tried. Against constraint 2: disableSourceOfProjectReferenceRedirect on the web project, and a tsconfig paths mapping, both leave TS6305 exactly as it was, because the redirect is applied after resolution and keyed on project membership. A vitest resolve.alias moves nothing, because tsc does not read it. Against constraint 3, a type-only import does not exempt the file. Against constraint 1, moving typescript into core's devDependencies changes nothing, because makeExternal reads dependencies only.
The composite and references wiring
packages/core is composite: true, and both apps/cli/tsconfig.json and apps/web/tsconfig.json declare a reference to it, but nothing in this repository runs tsc --build: the root typecheck script is separate tsc -p invocations. Two arms are open -- adopt tsc -b so the wiring is exercised, or remove the wiring as vestigial -- and this decision takes neither.
Removing it is a shared build-config change across three tsconfigs that buys nothing visible: npm run typecheck is green with both references deleted, so what the removal actually deletes is the TS6305 redirect, which fires only on the cross-workspace import pattern this decision declines to introduce. Adopting tsc -b is the larger change and cuts against the cost this whole question is about: building core as a composite project emits declarations for its included files into outDir, which for core is the dist it publishes, so the arm that would resolve the redirect does it by enlarging the shipped surface with core's test-file declarations.
Both arms wait on the same event: the wiring starts mattering when a cross-workspace .ts import exists to be REDIRECTED. The testkit's arrival is not that event, and the reason is the exemption above -- its source is reached through node_modules, where no composite redirect exists, so npm run typecheck is as green with it as without. An import that would exercise the wiring is one reaching a .ts inside core's own file list, which is the shape neither decision introduced.
What is still open
The operational form of this, in the place a contributor meets it, is in TESTING.md.
- The shipped
dist/testing.*surface is still unpriced. The export-map ruling above settled whether./testingstays a public subpath without pricing what it ships, so what is left to price it is a consumer raising it, or a package-size or supply-chain review. Until then the material on that channel stays where it is. - The composite/references arms above are untaken, and the testkit did not force them.
- The testkit's admission rule is held by review, not by a check. It has one subject; a second that does not meet the rule is how it starts becoming the junk drawer the first decision named.