test: pin selector-port behavior ahead of the P5 extraction (#1478) - #1552
Conversation
Pins, at existing root seams, the eight behavior cells the approved P5 amendment (issue #1478 comment 5156017698) requires the future packages/ad-replay selector port (readSelectorExpression / resolveRecordedTarget / buildSelectorCandidates) to preserve. Test-only — no production code changes.
Size Report
Startup median (7 runs, lower is better):
Top changed chunks: no changes in the largest emitted chunks. |
|
P2 — consolidate the P5 cell-7 wait-landmark pins instead of duplicating them. The new cases in |
Cell 7: relocate #1349's wait-landmark cases from selector-read.test.ts to selector-wait.test.ts (the 1:1 topology location for selector-wait.ts), replacing the weaker duplicate cell-7 cases added in the prior commit. The relocated tests keep the stronger assertions (real computeTargetEvidence- derived evidence, an initial no-match poll, observed-ancestry checks, and the plain-timeout-vs-landmark-mismatch distinction). Cell 5: the first case overlapped an existing later-alternative regression in session-replay-target-classification.test.ts. Sharpened it (rather than dropping it, since it is the only counterfactual-sensitive case for the allowDisambiguation=false skip path) to isolate the branch the existing regression's exact-tie fixture cannot reach, and paired it explicitly with the second case as a same-fixture, flag-flipped contrast.
|
Consolidated per review. Cell 7 (wait-landmark): dropped my two duplicate cases in Cell 5 (winner/domain same alternative): the first case did overlap Re-ran both counterfactuals after the move:
Gates ( Generated by Claude Code |
|
Summary
Step 2 of the approved P5 sequence for #1478 (issue comment 5156017698): pins, at existing root seams, the eight behavior cells the future
packages/ad-replayselector port (readSelectorExpression/resolveRecordedTarget/buildSelectorCandidates) must preserve. Test-only — no production code changes (git diff maintouches only*.test.tsfiles).Discipline followed (
docs/agents/testing.md): every test below was proven load-bearing by temporarily inverting the exact production condition it pins, watching it fail, and restoring the original code. Each cell's counterfactual — the mutation, the failure observed, and confirmation of the restore — is recorded below.Update after review: cells 5 and 7 were consolidated to remove duplicated coverage against pre-existing tests (see PR comment). Their entries below reflect the final locations and were re-verified against their counterfactuals post-move.
The 8 cells
Cell 1 — invalid selector vs valid-but-no-match
Lives in:
src/selectors/__tests__/selector-port-contract.test.tsPins that
tryParseSelectorChain(parse failure →null, before any resolution) andresolveSelectorChain(valid chain, zero matches →null) are two distinct, non-interchangeable failure shapes reached through different functions.Counterfactual: In
src/selectors/parse.ts, changedif (!ALL_KEYS.has(key))toif (false)so an unknown selector key likefoo=barparses instead of throwing.tryParseSelectorChain('foo=bar')returned a realSelectorChainobject instead ofnull— test failed onassert.equal(invalid, null). Restored; test green again.Cell 2 — fallback-alternative selection
Lives in:
src/selectors/__tests__/selector-port-contract.test.tsPins that
id="missing" || label="Save"resolves via the second alternative (selectorIndex: 1) when the first has zero matches.Counterfactual: In
src/selectors/resolve.ts, changed the alternative loop tochain.selectors.slice(0, 1).entries()so only the first alternative is ever tried. The resolution returnednullinstead of the second alternative's winner — test failed. Restored; test green again (cells 1/3/4 in the same file were unaffected by this change, confirming isolation).Cell 3 — ambiguity with and without disambiguation
Lives in:
src/selectors/__tests__/selector-port-contract.test.tsPins that the SAME two-way-tied match resolves to
nullwithdisambiguateAmbiguousunset, and to a disclosed winner (tiebreak: 'deepest',alternatives: ['e1']) with it enabled.Counterfactual: In
resolve.ts, changedif (!options.disambiguateAmbiguous || !summary.disambiguated || !summary.tiebreak) continue;to drop the!options.disambiguateAmbiguousclause, forcing disambiguation on unconditionally. The "without a tiebreak" half of the test failed (withoutTiebreakwas no longernull). Restored; test green again.Cell 4 —
requireRectLives in:
src/selectors/__tests__/selector-port-contract.test.tsPins that a matching, rect-less node is excluded when
requireRect: trueand included whenfalse, consistently betweenresolveSelectorChainandlistSelectorChainMatches.Counterfactual: In
resolve.ts'sanalyzeSelectorMatches, removed theif (requireRect && !node.rect) continue;guard. The rect-less node resolved even withrequireRect: true— test failed. Restored; test green again.Cell 5 — winner and matched-node domain from the same alternative
Lives in:
src/daemon/handlers/__tests__/session-replay-target-classification-port.test.ts(2 tests, sharpened per review to stop overlapping an existing regression)Pins that
classifyReplayTarget'smatchCount/domain always comes from the SAME chain alternativeresolveSelectorChainpicked as the winner — never a domain leaked from an alternative resolution skipped over.session-replay-target-classification.test.tsalready has a same-invariant regression, but its ambiguous first alternative is an EXACT tie (unresolvable regardless ofallowDisambiguation), so it only exercises the!summary.disambiguatedbranch ofresolveSelectorChain's skip condition. The two tests here instead hold ONE fixture fixed — a first alternative that IS resolvable, proven by the second test succeeding through it — and flip onlyallowDisambiguation, isolating the OTHER branch (!options.disambiguateAmbiguous): off, classification must use the second alternative's own domain (1), never leak the first's larger unused domain (3); on, the reverse (3, not the untried second alternative's 1).Counterfactual (re-verified post-sharpening): In
src/daemon/handlers/session-replay-target-classification.ts'sresolveSelectorTargetMatches, changed the domain filter frommatchesSelector(node, resolved.selector, platform)tomatchesSelector(node, chain.selectors[0]!, platform)(always the first alternative). TheallowDisambiguation: falsecase failed red (matchCount3 instead of the expected 1), as did the pre-existing repo regressionclassifyReplayTarget uses the later chain alternative that resolution selected after an earlier tie. TheallowDisambiguation: truecontrast case correctly stayed green (its own resolved alternative ISchain.selectors[0], so the mutation is a no-op for it — confirming it's genuinely the contrast case, not a second copy of the mechanism proof). Restored; all green again.Cell 6 — shared-ID demotion
Lives in:
src/replay/__tests__/target-identity-node.test.ts(new file —idMatchCountInTree/demoteNonUniqueLocalIdentityhad no direct unit test before, only indirect coverage throughsrc/selectors/build.test.ts's chain-building consumer)Pins the shared uniqueness predicate itself:
idMatchCountInTreecounts every node sharing a canonical (NFC, byte-capped) id;demoteNonUniqueLocalIdentitydrops ONLY theidtier, and only when it's non-unique, leaving role/label untouched.Counterfactual: Changed
idMatchCountInTreetoreturn 1;unconditionally. Both my new tests and the pre-existingsrc/selectors/build.test.ts(a second, independent consumer of the same predicate) failed red. Restored; both green again — confirming the cross-invariant that record-time evidence and the selector chain demote in lockstep through this ONE shared function.Cell 7 — wait-landmark identity mismatch (#1349)
Lives in:
src/commands/interaction/runtime/selector-wait.test.ts(relocated per review fromselector-read.test.ts, which is the 1:1 topology location forselector-wait.ts; 4 tests —runtime wait keeps polling past a same-selector impostor and succeeds on the recorded landmark,runtime wait fails closed at the deadline when only impostors matched the selector,runtime wait with a recorded landmark keeps the plain timeout when the selector never matched,runtime wait without a recorded landmark returns the satisfying match for record-time evidence)Pins that a landmark wait only succeeds once a poll's match carries the recorded identity (not on the first same-selector "impostor" match, and not before an initial no-match poll), refuses at the deadline with
WAIT_LANDMARK_MISMATCH_REASONplus the correctmatchCount/observed/observedAncestryevidence when every poll is an impostor, and keeps an undifferentiated plain timeout when the selector never matches at all (so the two failure modes stay distinguishable).Counterfactual (re-verified post-move): In
src/commands/interaction/runtime/selector-wait.ts'sresolveLandmarkMatch, changedif (!recorded) return { kind: 'satisfied', node: firstMatch };toif (true) return ...so any match satisfies the wait regardless of identity. At the new location, both landmark tests failed red (runtime wait keeps polling past a same-selector impostor...failed onwaitedMs >= 600;runtime wait fails closed at the deadline...failed onerror instanceof AppError), and the pre-existing daemon-level regressiona replayed wait with a landmark guard refuses at the deadline when only impostors matched(wait-landmark-recording.test.ts) failed red too. Restored; all green again.Cell 8 — repair-suggestion ordering
Lives in:
src/daemon/handlers/__tests__/session-replay-divergence-suggestion-port.test.ts(new file, targeting the previously-untested exportedbuildReplayDivergenceSuggestionForNode)Pins that a divergence/repair suggestion's
selectorstring preservesbuildSelectorChainForNode's priority order (id, then role+label, then label, then value) verbatim, and that a non-unique id (per cell 6's demotion) never appears in the suggestion at all.Counterfactual: In
src/selectors/build.ts, moved thevaluepush block to run before therole+labelblock. The exact-order string assertion failed (value="Draft"appeared second instead of fourth) — a casebuild.test.ts's existing fixtures don't exercise since none of them carry all four tiers at once. Restored; test green again.Verification
git diff main --stat— only*.test.tsfiles changed, zero production code.pnpm typecheck && pnpm lint && pnpm format:check && npx vitest run <the 9 touched files>— all green.npx vitest run(foreground, synchronous): 637/637 test files, 5300/5300 tests passed, zero failures.Test plan