Skip to content

test: end-to-end pipeline integration test, and the compiler bug it found - #70

Merged
myselfsiddharth merged 1 commit into
mainfrom
wave0/b0-integration-test
Jul 25, 2026
Merged

test: end-to-end pipeline integration test, and the compiler bug it found#70
myselfsiddharth merged 1 commit into
mainfrom
wave0/b0-integration-test

Conversation

@myselfsiddharth

Copy link
Copy Markdown
Contributor

Closes #52.

Adds tests/integration/pipeline.test.ts — the first test that crosses package boundaries. Every other suite tests one package in isolation, so the seams where the gate actually lives were verified only by humans reading four schemas.

The test records the fixture task, compiles it, pushes every row through the fail-closed cache write, replays the compiled program live in Chromium, and feeds the emitted metric rows to buildGateReport(). ~2s, no Docker, no model, no egress.

It found a real bug on its first run

synthesizeAssertion always preferred the acted-on control as the element-visible target, including for clicks. That post-condition isn't merely weak — it's usually false. A click that transitions the page hides the surface the control lived on:

step 3: REPAIR_EXHAUSTED — locator.waitFor: Timeout 5000ms exceeded.
  - waiting for getByTestId('login-button').first() to be visible
    14 × locator resolved to hidden <button type="submit" data-testid="login-button">Log in</button>

The compiler was asserting "the button I clicked is still visible" as the post-condition for logging in — which the login transition immediately falsifies.

The post_state landmark fallback doesn't rescue it. visible_landmarks is collected by walking the DOM without a visibility filter, so a landmark that was just hidden is still listed. The name overpromises; it's landmark roles present.

Fix: a click whose url_template changed skips the element-visible branch and is asserted on its destination via url-matches — which that branch already labels strong. So the assertion gets stronger, not weaker.

Narrow by construction: fills, non-navigating clicks, and navigate steps are untouched, and contracts/examples/trajectory.example.json has no click steps — so neither the committed bundle nor any existing expectation moves. Pinned by two new unit tests (navigating click → url-matches; non-navigating click → still element-visible).

This is scope beyond "add a test", and I want that visible rather than buried: the alternative was a skipped test documenting a bug, which protects nothing and can't gate CI.

Why loopback instead of file://

The issue said to open the fixture as a file:// URL. That turns out to be the wrong call and I'd rather say so than quietly comply.

file://{fixture_root}/... makes the parameter a whole filesystem path, and a path-valued hole spans / separators. The compiler's templateToRegex deliberately compiles holes to [^/?#]+ so a url-matches assertion can't skip across path segments. Widening that to fit the test would weaken every URL assertion the product emits — for a file:// quirk that no real recording has.

Serving the fixture over loopback gives http://{host}:{port}/..., which is single-segment holes and is exactly what npm run recorder -- --base-url produces against the Grafana testbed. The seam under test is now the representative one. Still hermetic — loopback, ~15 lines of node:http, no dependency.

Deliberate-breakage check

Required by the issue. Two rounds, and the first one is worth reporting:

Round 1 — corrupted testid/name/label on every compiled locator. Test still passed. Not a weak test: the fallback chain fell through to the structural and css_vocab candidates and resolved correctly. That's the chain doing its job, demonstrated accidentally.

Round 2 — replaced each chain wholesale with a single dead locator:

step 1: REPAIR_EXHAUSTED — no locator matched (testid: matched 0)

Test fails loudly, naming the step and the cause. Both edits reverted; working tree verified clean before commit.

Verification

  • npm run ci green — now includes test:integration
  • npm run test:canary green
  • Integration suite ~2s; full ci well inside budget
  • npm run typecheck caught an indexed-access widening on the params object, fixed with a typed FixtureParams

Notes for the reviewer

  • The bundle → CompiledProgram adapter is local to the test, per the issue's design note. The runtime doesn't need it, and promoting it would be a contract change (ADR territory).
  • docs/gate/compiler.md synthesis priority table updated, plus a new blind spot chore(deps-dev): bump typescript from 5.9.3 to 7.0.2 #8: the trajectory records no post-action visibility for the acted-on control, so a non-navigating click that hides its own control (modal close, no route change) is still mis-asserted. Fixing that properly means the recorder capturing post-action visibility — a trajectory.schema.json change, so an ADR.
  • A committed churn case ("replay against a mutated page fails honestly") is a natural follow-up. It belongs with Live matrix runner: drive a real browser per version (remove the exit-2 guard) #62, where churn is the subject, rather than here.

🤖 Generated with Claude Code

@myselfsiddharth
myselfsiddharth requested a review from a team as a code owner July 25, 2026 09:04
@github-actions
github-actions Bot requested a review from OM152002 July 25, 2026 09:04
@github-actions github-actions Bot added documentation Improvements or additions to documentation gate PRD section 9 gate measurement area: compiler Touches compiler area: tooling Touches tooling area: ci Touches ci size/L <= 600 changed lines labels Jul 25, 2026
@myselfsiddharth

Copy link
Copy Markdown
Contributor Author

Follow-up filed: #71Capture real visibility: fix visible_landmarks and record post-action target visibility (ADR-0007).

That is the root cause this PR deliberately did not fix. It is marked blocked by this PR (same branch of synthesizeAssertion, guaranteed conflict) and flagged to land before #24, since it changes contracts/trajectory.schema.json and doing it after a live trajectory exists means re-recording that trajectory across the matrix.

Filing it surfaced a consequence I had not written down here: src/runner/page-state.ts has the same unfiltered-visibility defect as the recorder, via querySelector. That output feeds RepairContext.page_state, so once #27 wires a real repair model the model gets handed hidden landmarks as visible context — misleading input that inflates measured repair cost against the §9 70%-of-fresh kill line. Scoped into #71 rather than expanded into this PR.

…arget

Adds tests/integration/pipeline.test.ts, the first test that crosses package
boundaries: record -> compile -> cache-write -> replay -> gate report, against
the bundled fixture. Wired into `npm run ci` and a new CI step. Runs in ~2s
with no Docker, no model, and no egress.

It found a real compiler bug on its first run, so the fix ships with it.

synthesizeAssertion always preferred the acted-on control as the
element-visible target, including for clicks. That post-condition is not
merely weak, it is usually false: a click that transitions the page hides the
surface the control lived on. Replaying the compiled login step timed out
waiting for the submit button to stay visible after it had been clicked.

The post_state landmark fallback does not rescue it either. visible_landmarks
is collected by walking the DOM with no visibility filter, so a landmark that
was just hidden is still listed - the name overpromises.

Fix: a click whose url_template changed skips the element-visible branch and
is asserted on its destination via url-matches, which that branch already
labels strong. Narrow by construction - fills, non-navigating clicks, and
navigate steps are untouched, and the example trajectory has no click steps,
so neither the committed bundle nor existing expectations move. Pinned by two
new unit tests.

The test serves the fixture over loopback instead of opening it as file://.
A file://{fixture_root}/... template makes the parameter a whole filesystem
path, and a path-valued hole spans / separators, which the compiler's
templateToRegex deliberately rejects (holes compile to [^/?#]+ so a url-matches
assertion cannot skip path segments). Loosening that to fit the test would
weaken every URL assertion the product emits. http://{host}:{port}/... is also
what a real recording looks like.

Refs #52

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@myselfsiddharth
myselfsiddharth force-pushed the wave0/b0-integration-test branch from 736e071 to c3a8a93 Compare July 25, 2026 09:25
@myselfsiddharth

Copy link
Copy Markdown
Contributor Author

Rebased onto main to clear the conflict with #69 (ci(testbed): smoke one pinned Grafana version on every PR).

One file conflicted: .github/workflows/ci.yml. Both changes were wanted and they are complementary rather than competing — #69 appended a new top-level testbed-smoke job, while this PR adds an Integration tests step inside the existing build job. Git could not tell them apart because both landed at the same anchor, immediately after Unit tests.

Resolved by keeping both, in the order each intended:

build:
  ... Unit tests
  ... Integration tests      <- this PR
testbed-smoke:               <- #69, unchanged
canary:

Nothing from #69 was dropped — its job body, timeout-minutes: 10, if: always() teardown, and the ci-smoke-assert.mjs step are byte-identical to what it merged. Verified by git diff --stat origin/main...HEAD, which shows only this PR's 7 files.

Re-verified against the new main, not just re-pushed. #69 also changed src/testbed/docker.ts, src/testbed/paths.ts (the composeProjectSlug fix), eslint.config.mjs, and tests/unit/testbed.test.ts, so a green pre-rebase run proved nothing about the merged result:

  • npm run ci green locally on the rebased branch, including test:integration
  • npm run test:canary green
  • On CI: lint-typecheck-test-secrets pass, privacy-canary pass, and testbed-smoke (grafana 11.0.0) pass in 31s — which is the real check on the resolution, since a botched merge of that job would have surfaced there rather than in my local run.

Force-pushed with --force-with-lease. Still blocked only on CODEOWNERS review.

@myselfsiddharth
myselfsiddharth enabled auto-merge (squash) July 25, 2026 09:31

@OM152002 OM152002 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Cool

@myselfsiddharth
myselfsiddharth merged commit 5a703e6 into main Jul 25, 2026
12 checks passed
@myselfsiddharth
myselfsiddharth deleted the wave0/b0-integration-test branch July 25, 2026 09:31
myselfsiddharth added a commit that referenced this pull request Jul 25, 2026
ADR-0007. #70 added the first end-to-end test and it caught the compiler
asserting "the button I clicked is still visible" as the post-condition for
logging in. That PR fixed only the navigating case by asserting the
destination. This closes the root cause underneath it, which was two separate
defects.

visible_landmarks did not mean visible. Both capture sites collected landmark
roles with no visibility filter - the recorder walked every element under
document.body, the runner used querySelector - so a landmark that had just
been hidden was still listed. On the fixture's login page that reported the
hidden app view's banner and navigation as visible, which is why the landmark
fallback could not rescue a mis-asserted click. Now filtered in both, with the
same in-page checkVisibility() predicate so the recorder and the repair
context cannot describe the same page differently. That second site matters:
capturePageState feeds RepairContext.page_state, so once #27 wires a real
model it would have been handed a page the recorder never saw.

Nothing recorded whether the acted-on control survived. Added optional
post_action_target_visible to trajectory.schema.json, observed with
Playwright's Locator.isVisible() - deliberately Playwright and not the DOM
predicate, because src/runner/assertions.ts later checks that same target with
waitFor state hidden/visible, so the recorder now claims exactly what the
runner will check.

The compiler consumes it: a click-like step whose target went visible ->
hidden is asserted element-visible with expected.visible false. Labelled
strong because for a dismiss-shaped control the disappearance is the purpose
of the step and the assertion fails on a no-op. A click that both navigates
and hides its control keeps url-matches - where it landed is better evidence
than what vanished.

Reproduced first with a self-hiding, non-navigating control added to the
fixture, which failed exactly as predicted before the fix:

  step 5: REPAIR_EXHAUSTED - locator.waitFor: Timeout 5000ms exceeded.
    14 x locator resolved to hidden <button data-testid="dismiss-notice">

Artifacts regenerated by command, never hand-edited. dom_digest shifts because
it is derived from the same structural signals. The example trajectory has no
clicks, so its committed bundle is byte-identical. Role and element counts stay
DOM-wide on purpose - they are structural, not visibility claims.

Closes #71

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
myselfsiddharth added a commit that referenced this pull request Jul 25, 2026
ADR-0007. #70 added the first end-to-end test and it caught the compiler
asserting "the button I clicked is still visible" as the post-condition for
logging in. That PR fixed only the navigating case by asserting the
destination. This closes the root cause underneath it, which was two separate
defects.

visible_landmarks did not mean visible. Both capture sites collected landmark
roles with no visibility filter - the recorder walked every element under
document.body, the runner used querySelector - so a landmark that had just
been hidden was still listed. On the fixture's login page that reported the
hidden app view's banner and navigation as visible, which is why the landmark
fallback could not rescue a mis-asserted click. Now filtered in both, with the
same in-page checkVisibility() predicate so the recorder and the repair
context cannot describe the same page differently. That second site matters:
capturePageState feeds RepairContext.page_state, so once #27 wires a real
model it would have been handed a page the recorder never saw.

Nothing recorded whether the acted-on control survived. Added optional
post_action_target_visible to trajectory.schema.json, observed with
Playwright's Locator.isVisible() - deliberately Playwright and not the DOM
predicate, because src/runner/assertions.ts later checks that same target with
waitFor state hidden/visible, so the recorder now claims exactly what the
runner will check.

The compiler consumes it: a click-like step whose target went visible ->
hidden is asserted element-visible with expected.visible false. Labelled
strong because for a dismiss-shaped control the disappearance is the purpose
of the step and the assertion fails on a no-op. A click that both navigates
and hides its control keeps url-matches - where it landed is better evidence
than what vanished.

Reproduced first with a self-hiding, non-navigating control added to the
fixture, which failed exactly as predicted before the fix:

  step 5: REPAIR_EXHAUSTED - locator.waitFor: Timeout 5000ms exceeded.
    14 x locator resolved to hidden <button data-testid="dismiss-notice">

Artifacts regenerated by command, never hand-edited. dom_digest shifts because
it is derived from the same structural signals. The example trajectory has no
clicks, so its committed bundle is byte-identical. Role and element counts stay
DOM-wide on purpose - they are structural, not visibility claims.

Closes #71

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
myselfsiddharth added a commit that referenced this pull request Jul 25, 2026
architecture.md was written against 6ad7151, before #70 merged, so it still
claimed the bundle -> cache -> runner seam was untested. It is now exercised
end to end by tests/integration/pipeline.test.ts. What remains true, and is
the useful statement, is narrower: no *product* path walks that seam - only a
test does, and the adapter lives in the test on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
myselfsiddharth added a commit that referenced this pull request Jul 28, 2026
…#73)

* feat: capture real visibility and post-action target state (ADR-0007)

ADR-0007. #70 added the first end-to-end test and it caught the compiler
asserting "the button I clicked is still visible" as the post-condition for
logging in. That PR fixed only the navigating case by asserting the
destination. This closes the root cause underneath it, which was two separate
defects.

visible_landmarks did not mean visible. Both capture sites collected landmark
roles with no visibility filter - the recorder walked every element under
document.body, the runner used querySelector - so a landmark that had just
been hidden was still listed. On the fixture's login page that reported the
hidden app view's banner and navigation as visible, which is why the landmark
fallback could not rescue a mis-asserted click. Now filtered in both, with the
same in-page checkVisibility() predicate so the recorder and the repair
context cannot describe the same page differently. That second site matters:
capturePageState feeds RepairContext.page_state, so once #27 wires a real
model it would have been handed a page the recorder never saw.

Nothing recorded whether the acted-on control survived. Added optional
post_action_target_visible to trajectory.schema.json, observed with
Playwright's Locator.isVisible() - deliberately Playwright and not the DOM
predicate, because src/runner/assertions.ts later checks that same target with
waitFor state hidden/visible, so the recorder now claims exactly what the
runner will check.

The compiler consumes it: a click-like step whose target went visible ->
hidden is asserted element-visible with expected.visible false. Labelled
strong because for a dismiss-shaped control the disappearance is the purpose
of the step and the assertion fails on a no-op. A click that both navigates
and hides its control keeps url-matches - where it landed is better evidence
than what vanished.

Reproduced first with a self-hiding, non-navigating control added to the
fixture, which failed exactly as predicted before the fix:

  step 5: REPAIR_EXHAUSTED - locator.waitFor: Timeout 5000ms exceeded.
    14 x locator resolved to hidden <button data-testid="dismiss-notice">

Artifacts regenerated by command, never hand-edited. dom_digest shifts because
it is derived from the same structural signals. The example trajectory has no
clicks, so its committed bundle is byte-identical. Role and element counts stay
DOM-wide on purpose - they are structural, not visibility claims.

Closes #71

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(architecture): narrow the unwired-seam row after #52

architecture.md was written against 6ad7151, before #70 merged, so it still
claimed the bundle -> cache -> runner seam was untested. It is now exercised
end to end by tests/integration/pipeline.test.ts. What remains true, and is
the useful statement, is narrower: no *product* path walks that seam - only a
test does, and the adapter lives in the test on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: ci Touches ci area: compiler Touches compiler area: tooling Touches tooling documentation Improvements or additions to documentation gate PRD section 9 gate measurement size/L <= 600 changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add end-to-end integration test: record → compile → cache-write → replay

2 participants