test: useLiveQuery conformance suite across all five framework adapters (RFC #1623)#1636
Conversation
Introduce a shared, framework-agnostic conformance harness for the `useLiveQuery` adapters (RFC #1623, Phase 1). Each adapter supplies a thin driver that implements a common contract; the shared suite runs one behavioral spec against all of them. - contract.ts: the LiveQueryDriver contract (realm-safe injection of collection factories + query operators, so scenarios never import @tanstack/db directly and avoid the dual-package instanceof mismatch). - suite.ts: 24 scenarios sourced bottom-up from the union of the existing adapter test suites (spine + gap-closers), plus the #1601 order-only-move case as a universal expected-fail. Per-adapter knownGaps mark behaviors an adapter does not yet satisfy (populated empirically, not from the matrix). - react-db: React reference driver. Its only knownGap beyond the universal #1601 is precreated-not-syncing-isready-false — React eagerly starts sync on mount, real cross-adapter drift the suite surfaced. Covers query/liveness, join/groupBy/aggregate/includes, findOne cardinality, disabled + transitions, deferred readiness/eager, param recompile, optimistic mutation, pre-created/config-object inputs, and error status. Vue/Svelte/Solid/Angular drivers to follow, one per PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the vue-db adapter into the shared live-query conformance suite. Vue composables run inside an effectScope so unmount disposes via scope.stop(). Vue passes all 24 scenarios (knownGaps empty) — including findOne cardinality, which vue-db had zero tests for. The suite closes that coverage gap. Also correct the `precreated-not-syncing-isready-false` scenario: it now builds the live query over a not-ready (deferred) source rather than a ready one. The original wrongly asserted an eager-start implementation detail — both React and Vue eagerly start a pre-created collection on mount, so isReady is only false when the source itself never readies. React's spurious knownGap is dropped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughIntroduces a shared cross-framework live-query conformance test contract and suite ( ChangesShared Conformance Contract and Suite
Framework Driver Implementations
Estimated code review effort: 3 (Moderate) | ~30 minutes Sequence Diagram(s)sequenceDiagram
participant TestRunner
participant runSuite
participant FrameworkDriver
participant LiveQueryHandle
TestRunner->>runSuite: register scenario
runSuite->>runSuite: resolve expected-fail via knownGaps/UNIVERSAL_EXPECTED_FAIL
runSuite->>FrameworkDriver: mount/mountDeferred/mountControllable(...)
FrameworkDriver->>LiveQueryHandle: return handle
runSuite->>LiveQueryHandle: current()
runSuite->>LiveQueryHandle: flush()/apply()
runSuite->>LiveQueryHandle: unmount()
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
More templates
@tanstack/angular-db
@tanstack/browser-db-sqlite-persistence
@tanstack/capacitor-db-sqlite-persistence
@tanstack/cloudflare-durable-objects-db-sqlite-persistence
@tanstack/db
@tanstack/db-ivm
@tanstack/db-sqlite-persistence-core
@tanstack/electric-db-collection
@tanstack/electron-db-sqlite-persistence
@tanstack/expo-db-sqlite-persistence
@tanstack/node-db-sqlite-persistence
@tanstack/offline-transactions
@tanstack/powersync-db-collection
@tanstack/query-db-collection
@tanstack/react-db
@tanstack/react-native-db-sqlite-persistence
@tanstack/rxdb-db-collection
@tanstack/solid-db
@tanstack/svelte-db
@tanstack/tauri-db-sqlite-persistence
@tanstack/trailbase-db-collection
@tanstack/vue-db
commit: |
|
Size Change: 0 B Total Size: 125 kB ℹ️ View Unchanged
|
|
Size Change: 0 B Total Size: 4.26 kB ℹ️ View Unchanged
|
Wire svelte-db into the shared suite. Svelte composables run inside a
persistent $effect.root (disposed on unmount); reads happen after flushSync().
The suite caught a real bug: svelte-db's toValue() unwrapping — added to support
the reactive `() => collection` input form — calls a disabled query fn like
`() => null` as if it were a getter, unwraps it to null, and passes {...null}
into createLiveQueryCollection, crashing in getQueryIR. The disabled
short-circuit is unreachable for this case, and svelte-db has no disabled tests.
Recorded as knownGaps (disabled-explicit, disabled-transition) until fixed.
All other 22 scenarios pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire solid-db into the shared suite. Each mount runs inside createRoot (disposed on unmount); Solid auto-tracks signals so controllable inputs read a signal in the query fn, and collection/config inputs are passed as accessors per Solid's arity-based input detection. The suite surfaced a divergence: solid-db routes errors through its createResource/Suspense path, which throws CollectionStateError for an <ErrorBoundary> to catch, rather than exposing a readable isError flag like React/Vue/Svelte. Recorded as a knownGap (error-status). All other 23 scenarios pass, including findOne, optimistic reconcile, and disabled transitions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire angular-db into the shared suite. Each mount runs injectLiveQuery inside a
child EnvironmentInjector created off TestBed's, disposed on unmount to fire the
DestroyRef cleanup. Controllable inputs use Angular's reactive { params, query }
form driven by a signal.
The suite surfaced a divergence: angular-db's plain `{ query }` config-object
path calls createLiveQueryCollection(opts) without injecting startSync:true (its
query-fn path does), so a bare config never syncs and returns empty —
React/Vue/Svelte/Solid all auto-start config objects. Recorded as a knownGap
(config-object-input).
All other 23 scenarios pass, including error status (Angular exposes isError
directly, unlike Solid's ErrorBoundary throw).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
makeHandle typed its param as ReturnType<typeof renderHook>
(RenderHookResult<unknown, unknown>), but mountControllable passes a typed
hook (RenderHookResult<..., { param: P }>), which fails vitest's project
typecheck on rerender's contravariant props. Widen the param to
RenderHookResult<any, any>. Reproduced locally via `vitest --run` (typecheck
enabled); now green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
KyleAMathews
left a comment
There was a problem hiding this comment.
I think the conformance-suite shape is good, and I’m approving this direction. I did a pass over an external review and validated a few test-infrastructure issues that seem worth addressing, either in this PR or a quick follow-up:
-
The React conformance driver currently derives normalized
isEnabledfromstatus !== 'disabled', so the suite doesn’t actually catch a broken ReactisEnabledvalue. I verified by intentionally returningisEnabled: truefor disabled React queries: the conformance test still passed. Mapping React’s normalized field fromr.isEnabledmakes the disabled conformance scenarios catch it. For adapters that don’t expose a realisEnabledfield/signal, deriving from status is fine, but the normalized field should probably be named/treated honestly as status-derived. -
Expected-fail scenarios can leak mounted framework roots because
h.unmount()is after assertions. I instrumented Solid roots and saw leaks from the expected-failerror-statusand universalorder-only-movescenarios. Wrapping mounted handles intry/finally { h.unmount() }fixes it. -
knownGapsshould be validated against registered scenario keys. I added a bogus React gap and the suite still passed. A smallscenarioKeysset plus validation test catches stale/misspelled gaps. -
includes-subqueryis under-asserted. The suggested stronger assertion is directionally right, thoughjohnSmith.issuesis a child collection rather than[], so the strengthened assertion should read child collection contents through the collection API. -
The
suite.tsheader is stale: it says engine-heavy scenarios are “ported next,” but this PR already includes them.
I also checked for bidi/Trojan Source controls in the added conformance files and didn’t find any, so I wouldn’t block on that.
The React driver derived normalized `isEnabled` from `status !== 'disabled'`, so the suite could not catch a broken react-db `isEnabled` (returning a wrong value still passed). Map it from `r.isEnabled` instead. The other four adapters expose no `isEnabled`, so they keep the status-derived value with an explicit comment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iew #2) Expected-fail scenarios throw at the assertion, before their `h.unmount()`, so the mounted framework root leaked (observed with Solid on error-status and the universal order-only-move). Wrap the driver's mount* methods to track handles and tear them all down in a finally around each scenario, so teardown runs regardless of whether the scenario throws. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eview #3) A stale or misspelled knownGap silently no-ops (a bogus key just doesn't mark any scenario), which is a live footgun while gaps churn across the follow-up fixes. Register every scenario key and add a test asserting each driver's knownGaps and UNIVERSAL_EXPECTED_FAIL reference a real scenario. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The includes scenario only checked that `john.issues` was defined. It's a child collection, so read its contents through the collection API and assert John (id 1) has exactly issues i1 and i3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The header said engine-heavy scenarios were "ported next"; they've been in the suite for a while. Replace the stale STATUS note with the actual coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the careful pass, Kyle — all five were legit. Addressed each as its own commit on this branch:
All five suites stay green at 25/25 (the +1 is the new |
Remove references to the (uncommitted) RFC and to GitHub issue numbers from the conformance suite comments and driver headers; the behavior descriptions stay. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (4)
packages/db/tests/conformance/suite.ts (3)
88-94: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winSilent teardown catch-all may mask real driver bugs.
Every
handle.unmount()failure is swallowed unconditionally. Since the whole point of this suite is to surface adapter-specific defects, an unmount that throws for a genuine bug (not just idempotent double-unmount) will be silently hidden rather than surfaced as a signal for someone to investigate.♻️ Suggested tweak: log instead of fully silencing
for (const handle of handles) { try { handle.unmount() - } catch { - // teardown is best-effort / idempotent + } catch (err) { + // teardown is best-effort / idempotent, but still surface unexpected + // failures for diagnosis instead of hiding them entirely. + console.warn(`[conformance] handle.unmount() failed during teardown:`, err) } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/db/tests/conformance/suite.ts` around lines 88 - 94, The teardown loop in the conformance suite is swallowing every `handle.unmount()` error, which can hide real adapter bugs. Update the `handles` cleanup block in `suite.ts` so `handle.unmount()` failures are not silently ignored; instead, log the caught error with enough context to identify which handle failed, while still keeping teardown best-effort and idempotent.
104-176: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRepeated
makeSource + mount(select id) + flushboilerplate across scenarios.Several scenarios (
basic-select,live-insert,live-delete,orderby,live-update, etc.) repeat the samedriver.makeSource(SEED)→driver.mount(...select id...)→await h.flush()setup. Extracting a small helper (e.g.mountIdSelect(source)) would reduce duplication and keep each scenario body focused on its assertion.As per coding guidelines, "Extract common logic into utility functions when identical or near-identical code blocks appear in multiple places."
Also applies to: 358-400
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/db/tests/conformance/suite.ts` around lines 104 - 176, The test scenarios repeat the same driver.makeSource(SEED) and driver.mount(...) setup with an id-only select, so extract that shared initialization into a small helper and reuse it across the conformance scenarios. Add a utility such as a mount helper near the suite-level test setup, then update the affected scenarios like basic-select, live-insert, live-delete, and orderby to call it and keep only the scenario-specific assertions and mutations.Source: Coding guidelines
104-676: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy liftPervasive
anytyping in scenario callbacks.Nearly every query-builder callback throughout the file is typed as
(q: any)/({ items }: any)/({ items, persons }: any), etc. (e.g. Lines 112-113, 130-131, 244-251, 296-313, 332-339). This runs counter to the guideline requiringunknown+ type guards overany. Given each adapter passes its own framework-specific generic query builder, fully eliminatinganymay require nontrivial generic plumbing throughcontract.ts'sQueryBuildtype — worth considering for a follow-up rather than blocking this draft suite.As per coding guidelines, "Avoid using
anytypes; useunknowninstead when the type is truly unknown, and provide proper type annotations for return values."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/db/tests/conformance/suite.ts` around lines 104 - 676, The scenario callbacks in this conformance suite rely on pervasive any annotations in query-builder lambdas, which conflicts with the typing guideline. Replace these callback parameters in the suite’s scenario definitions and query expressions (such as the mounts using q, items, persons, issues, ic) with properly typed generics or unknown plus narrowing, using the relevant driver/query-builder types instead of any. If full typing is not practical here, wire the generic query-builder type through the shared QueryBuild/contract layer so the callbacks can infer concrete shapes without any.Source: Coding guidelines
packages/react-db/tests/conformance.test.tsx (1)
41-121: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winIdentical boilerplate duplicated across all 5 driver files.
writer,makeSource,makeDeferredSource,makePrecreated, andmakeErrorSourceare byte-for-byte identical (modulo the id prefix string) in the Angular, Vue, Svelte, and Solid driver files. Since each only needscreateCollection,createLiveQueryCollection,mockSyncCollectionOptions, andmockSyncCollectionOptionsNoInitialState(all realm-specific), this can be extracted into a shared parameterized factory inpackages/db/tests/conformance/that each driver calls with its own realm imports and prefix.The PR notes state shared-logic extraction is deferred to a later stacked refactor, so this is a good-to-have rather than a blocker for this draft.
♻️ Illustrative extraction (outside current file's line range)
// packages/db/tests/conformance/sourceFactories.ts export function createSourceFactories<Realm extends { createCollection: typeof import('`@tanstack/db`').createCollection createLiveQueryCollection: typeof import('`@tanstack/db`').createLiveQueryCollection }>(realm: Realm, mocks: { mockSyncCollectionOptions: typeof mockSyncCollectionOptions mockSyncCollectionOptionsNoInitialState: typeof mockSyncCollectionOptionsNoInitialState }, prefix: string) { let sourceSeq = 0 function writer<T extends { id: string }>(collection: any) { /* shared body */ } function makeSource<T extends { id: string }>(initialData: ReadonlyArray<T>) { /* shared body */ } function makeDeferredSource<T extends { id: string }>() { /* shared body */ } function makePrecreated(build: QueryBuild, opts?: { startSync?: boolean }) { /* shared body */ } function makeErrorSource() { /* shared body */ } return { makeSource, makeDeferredSource, makePrecreated, makeErrorSource } }Then this file's lines 41-121 collapse to a single call:
-let sourceSeq = 0 - -function writer<T extends { id: string }>(collection: any) { ... } -function makeSource<T extends { id: string }>(...) { ... } -function makeDeferredSource<T extends { id: string }>() { ... } -function makePrecreated(build: QueryBuild, opts?: { startSync?: boolean }) { ... } -function makeErrorSource() { ... } +const { makeSource, makeDeferredSource, makePrecreated, makeErrorSource } = + createSourceFactories( + { createCollection, createLiveQueryCollection }, + { mockSyncCollectionOptions, mockSyncCollectionOptionsNoInitialState }, + `conformance-react`, + )As per coding guidelines, "Extract common logic into utility functions when identical or near-identical code blocks appear in multiple places."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/react-db/tests/conformance.test.tsx` around lines 41 - 121, The conformance test helpers here are duplicated across the driver files, so extract the shared boilerplate into a parameterized factory under packages/db/tests/conformance and have this file consume it instead of defining writer, makeSource, makeDeferredSource, makePrecreated, and makeErrorSource locally. Keep the realm-specific pieces (createCollection, createLiveQueryCollection, mockSyncCollectionOptions, mockSyncCollectionOptionsNoInitialState, and the id prefix/sourceSeq handling) as injected dependencies so each driver can reuse the same implementation.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/db/tests/conformance/suite.ts`:
- Around line 88-94: The teardown loop in the conformance suite is swallowing
every `handle.unmount()` error, which can hide real adapter bugs. Update the
`handles` cleanup block in `suite.ts` so `handle.unmount()` failures are not
silently ignored; instead, log the caught error with enough context to identify
which handle failed, while still keeping teardown best-effort and idempotent.
- Around line 104-176: The test scenarios repeat the same
driver.makeSource(SEED) and driver.mount(...) setup with an id-only select, so
extract that shared initialization into a small helper and reuse it across the
conformance scenarios. Add a utility such as a mount helper near the suite-level
test setup, then update the affected scenarios like basic-select, live-insert,
live-delete, and orderby to call it and keep only the scenario-specific
assertions and mutations.
- Around line 104-676: The scenario callbacks in this conformance suite rely on
pervasive any annotations in query-builder lambdas, which conflicts with the
typing guideline. Replace these callback parameters in the suite’s scenario
definitions and query expressions (such as the mounts using q, items, persons,
issues, ic) with properly typed generics or unknown plus narrowing, using the
relevant driver/query-builder types instead of any. If full typing is not
practical here, wire the generic query-builder type through the shared
QueryBuild/contract layer so the callbacks can infer concrete shapes without
any.
In `@packages/react-db/tests/conformance.test.tsx`:
- Around line 41-121: The conformance test helpers here are duplicated across
the driver files, so extract the shared boilerplate into a parameterized factory
under packages/db/tests/conformance and have this file consume it instead of
defining writer, makeSource, makeDeferredSource, makePrecreated, and
makeErrorSource locally. Keep the realm-specific pieces (createCollection,
createLiveQueryCollection, mockSyncCollectionOptions,
mockSyncCollectionOptionsNoInitialState, and the id prefix/sourceSeq handling)
as injected dependencies so each driver can reuse the same implementation.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 6fd94bb8-a36a-4a1d-b201-622045a3d01b
📒 Files selected for processing (7)
packages/angular-db/tests/conformance.test.tspackages/db/tests/conformance/contract.tspackages/db/tests/conformance/suite.tspackages/react-db/tests/conformance.test.tsxpackages/solid-db/tests/conformance.test.tsxpackages/svelte-db/tests/conformance.svelte.test.tspackages/vue-db/tests/conformance.test.ts
Draft — first slice of RFC #1623 (framework-binding platform). Discussion: #1623
Scope — this is test infrastructure only
This PR is entirely test-only — no
src/changes, no shared production code. It does not extract the duplicated adapter logic into a shared observer/module; that's the next milestone (RFC step 2+). This PR builds the safety net that must exist before that extraction, so we can rip shared logic out of five adapters and prove behavior is unchanged.The per-framework files here (
packages/*/tests/conformance.test.*) are small test harnesses, not new production abstractions. Each just wraps its framework's existinguseLiveQuery(mount → read → flush → unmount) so one shared spec can run against all five. (In the code the harness interface is namedLiveQueryDriver— "driver" in the JUnit/conformance-testing sense of "the thing that drives the tests," not a runtime adapter.)What this is
A shared, framework-agnostic conformance suite for the
useLiveQueryadapters. One behavioral spec (packages/db/tests/conformance/suite.ts, 24 scenarios) runs against all five adapters through a thin per-framework test harness implementing a common contract (packages/db/tests/conformance/contract.ts).This is deliberately the lowest-risk, highest-consensus piece of #1623: it documents current behavior, commits to no architecture, and becomes the safety net for the later observer/adapter refactor.
How it's sourced
Bottom-up from the union of the existing adapter test suites — every fixed bug already left a regression test behind, so those tests are the real contract. The diff between adapters' suites is the drift #1623 is about, so folding them into one spec surfaces it. The RFC's aspirational list contributes only the small additive tail (the #1601 order-only-move case, a universal expected-fail).
Design notes
@tanstack/db. Scenarios never import@tanstack/db, avoiding the dual-packageinstanceof CollectionImplmismatch.knownGaps, populated empirically. Each scenario runs asitorit.failsper adapter based onknownGaps. A key is added only when it actually fails on a run — the matrix says where to look, the run says what's broken. When a gap closes,it.failserrors ("expected to fail but passed") prompting removal. So drift and bugs are documented without the suite going red, and theknownGapslists double as the follow-up to-do list.apply(fn)handle primitive. A cross-framework "run a mutation inside the framework's update scope, then settle" (Reactact/ VuenextTick/ SvelteflushSync/ Solidbatch) — needed for the optimistic-mutation scenario.Coverage (24 scenarios)
Query/where/select, live insert/update/delete, orderBy, join, groupBy/aggregate, nested aggregates,
.includessubqueries, findOne cardinality (+ reactive update/delete), disabled + enable/disable transitions, deferred readiness / eager-visible-while-loading / ready-with-no-data, param recompilation, optimistic mutation, pre-created & config-object inputs, error status, and the #1601 order-only-move tail.Results — all five adapters covered
Each passes 24/24 (documented
knownGapsrun as expected-fail). The suite surfaced three real cross-adapter divergences/bugs:findOnecoverage gap (vue-db had zero findOne tests; it works, was just untested)disabled-explicit,disabled-transitiontoValue()(added for the reactive() => collectionform) calls a() => nulldisabled query as a getter, unwraps it to null, and passes{...null}intocreateLiveQueryCollection→ crash ingetQueryIR. The disabled short-circuit is unreachable; svelte-db has no disabled tests.error-statusCollectionStateError) through thecreateResource/Suspense path for an<ErrorBoundary>, not via a readableisErrorflag like the others.config-object-input{ query }config path callscreateLiveQueryCollection(opts)without injectingstartSync: true(its query-fn path does), so a bare config never syncs. The others auto-start; Angular requires explicitstartSync: true.Also confirmed not drift: every adapter eagerly starts a pre-created collection on mount (an earlier scenario wrongly tested this as React-specific; corrected to build over a not-ready source so it measures the actual contract — isReady stays false only when the source never readies).
Out of scope for this suite (by design)
Framework-specific tests (Solid proxy identity, Svelte runes, React overloads, Angular DI lifecycle) stay per-adapter. Separate hook families (
useLiveInfiniteQuery,useLiveSuspenseQuery,useLiveQueryEffect) get their own suites later.Follow-up PRs (stacked on this branch)
Each clears one
knownGapand is stacked on this branch (re-target tomainonce this lands):fix(svelte-db): the disabled-query crash (the only outright bug). Clearsdisabled-explicit+disabled-transition.fix(angular-db): config-object input now syncs (defaultsstartSync: true). Clearsconfig-object-input.test(conformance): parametrizes error surfacing (errorSurface: 'flag' | 'throw') rather than "fixing" Solid's idiomatic ErrorBoundary model. Clearserror-status.Each removes its
knownGapentry, so the previously-it.failsscenario runs as a normal passing test — the suite itself proves the fix.Next milestone (NOT this PR)
The actual duplication cleanup — extracting the ~7 items each adapter copies (input normalization, disabled handling, cardinality, status derivation, ready-race, etc.) into a shared observer/module in
@tanstack/db, then slimming each adapter'ssrc/useLiveQuerydown to it — is a separate follow-up. That's the PR that will showsrc/changes and shrink duplication. This suite exists to keep it honest: it must stay green through that refactor.🤖 Generated with Claude Code
Summary by CodeRabbit