Skip to content

test(e2e): check the GraphQL mock's answers against GitHub's schema - #615

Merged
Vivswan merged 4 commits into
mainfrom
test/e2e-graphql-mock-answers
Nov 12, 2026
Merged

Vivswan merged 4 commits into
mainfrom
test/e2e-graphql-mock-answers

Conversation

@Vivswan

@Vivswan Vivswan commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner

Before

handler -> data, as the handler built it -> product
$ bun test/e2e/run.ts --scenario wildcard-null-enforce-admins | sed -n '3,/passed$/p'   # the tree before #599, its own mock
  FAIL  wildcard-null-enforce-admins
          convergence: rerun exited 1, expected 0

0/1 passed

The runner's line names no cause. The product's own error, in the rerun's stdout, does:

$ grep -h '^::error::' test/e2e/.artifacts/wildcard-null-enforce-admins-*/rerun-0-converges-check/stdout.txt
::error::branches: GRAPHQL BranchProtectionRules returned a body outside the documented shape - [0].isAdminEnforced: Invalid input: expected boolean, received null. Check the "api-version" input against the GitHub GraphQL reference for this operation

After

handler -> data -> graphql-js completes it under the request's document -> on GitHub's schema: the selection -> product
                                                                        -> off it: mock violation, the scenario fails
$ bun test/e2e/run.ts --scenario wildcard-null-enforce-admins | sed -n '3,/passed$/p'   # the same tree, this branch's mock
  FAIL  wildcard-null-enforce-admins
          convergence: rerun exited 1, expected 0
          convergence: mock violations:
            GraphQL BranchProtectionRules was answered off GitHub's schema by the mock handler "branches.rulesQuery": data.repository.branchProtectionRules.nodes.0.isAdminEnforced: Cannot return null for non-nullable field BranchProtectionRule.isAdminEnforced.

0/1 passed

This branch: PASS.

The check adds: the cause on the runner's line, and the handler's name.

Silent without a re-read: the mutation's document does not select the field.

For the owner: the mock stores a null a rule mutation sends. What GitHub does with that null is not known here, so a run that sends one fails at the next read.

For the owner: a wildcard required_linear_history: null on main goes red on a re-read, live true or false. No corpus scenario holds it.

How

  • Completed data goes out: the selection only.
  • It must equal the handler's data: only null under an omitted nullable key may differ.
  • Library: graphql's executeRootSelectionSet. Nothing under src/ changes.

Proof

  • Unit: 247 pass, 0 fail in bun test test/e2e/mock.
  • Slice: 126/126 passed, the three sections with GraphQL.
Technical details
  • Witness, step 1: git archive 346b640a^ | tar -x -C <dir>, link node_modules, run bun run build:schema, save the first scenario below under test/e2e/scenarios/, run the Before command in <dir>.
  • Witness, step 2: copy this branch's test/e2e/mock/ over <dir>/test/e2e/mock/ and run the same command for the After block.
  • On this branch: the same scenario saved under test/e2e/scenarios/ prints PASS and 1/1 passed.
  • Without a re-read: with the fixpoint line removed, step 2 prints PASS and 1/1 passed.
  • Why the Before is red: the product's own parse of the rule node refused the stored null on the re-read.
  • The product's line: the grep under Before, run in <dir> after the Before command, reads the rerun's stdout from the artifacts that run left.
  • The violation names: the operation, the handler key, the response path, and graphql-js's message, or both values where the data completes to another value.
  • Its log entry: the operation's own, so the runner still counts a refused read as a read.
  • No scenario key tolerates it: the primary run, the convergence re-run, both apply-idempotence re-runs, and the snapshot round trip each report a mock violation as a failure.
  • Keys that no longer reach the product: the branches.createRule and branches.updateRule handlers answer the whole rule node, and their documents select id pattern requiresDeployments requiredDeploymentEnvironments.
  • The count: the rule wire node has 22 keys and each of the two documents selects 4, so 18 keys no longer reach the product.
  • Count command: bun -e 'import { completeRule, ruleWireNode } from "./test/e2e/mock/state.ts"; console.log(Object.keys(ruleWireNode(completeRule({ id: "BPR_1", pattern: "release/*" }))).length)' prints 22.
  • The product reads only selected fields there: verifyDeploymentReadback reads requiresDeployments and requiredDeploymentEnvironments.
  • Equal after completion: an object position holds an object, a list an array of the same length, and a leaf is ===.
  • A coerced or filled-in value is a violation: true for an Int completes to 1, 5 for an ID to "5", and 7 at an object position to an object of nulls.
  • Its line: data.deleteBranchProtectionRule: answered 7, the schema serves {"clientMutationId":null}.
  • What it does not catch: a stored value that no later request selects.
  • What it does not catch: an errors answer, which the OpenAPI validator's GraphQL response contract owns.
  • What it does not catch: the section suites' fake in test/sections/fragment-fake.ts, which still hands a handler's data over as built.
  • What it does not catch: a custom scalar's format, since the SDL gives a custom scalar identity serialization.
  • Abstract types: graphql-js reads the concrete type from the data's __typename, so a union value without one is a violation.
  • Aliases: a field is read under its response key.
  • Recorded, not built: a document that aliases __typename on an abstract field still needs __typename on the answer, or the check reports a violation.
  • Recorded, not built: a document that selects __typename on a concrete type needs it on the answer too, or the check reports a violation, and no declared document selects it there.
  • Recorded, not built: an answer that is not JSON (a cyclic object, a BigInt) makes the check's message throw, which the mock server turns into a violation of its own, and a very large wrong value prints whole.
  • No declared document has one: the 14 declared documents hold no aliased field.
  • Chaos: a corrupted answer is built after the guard, from the completed data.
  • Request module: graphql-request.ts exports acceptedGraphqlRequest(), and graphqlRequestErrors() wraps it for the section suites' fake.
  • Linear-history probe: the second scenario below, saved under test/e2e/scenarios/ on this branch, prints the block under it for requiresLinearHistory: true and for false.
  • No corpus scenario holds it: grep -rl "required_linear_history: null" test --include='*.yml' prints nothing.
  • Noticed, not changed here: the guard for an undeclared error type answers through the base log entry, which renders POST /graphql and counts as a write.
  • Red on main's pipeline: the new test file, graphql-answer.ts, and graphql-request.ts copied onto an export of main, which keeps its routes.ts: 5 fail, 13 pass.
  • The 5: the two selection cases, the two violations, and the in-run control.
  • The 13: the five off-schema cases, the six equality cases, and the two control: tests, which call the new module directly or serve data that is already the selection.
  • In-run control: one test runs the product's own code in-process through runSingle against the mock server, twice.
  • Its control half: the handlers as they are give clean and no violation.
  • Its mutant half: a branches.rulesQuery handler answering the null gives failed and the violation on the mock's list, the list the runner fails a scenario on.
  • Why the list and not the result: on main's pipeline that run fails too, on the product's own parse, and its list stays empty.
  • The last link: roundTripFailures (the snapshot round trip's verdict) in test/e2e/runner/runner.test.ts pins that a mock violation fails a snapshot round trip.
  • Noticed, not changed here: no test pins the same link for the primary run's verdictFailures, the convergence re-run, or the apply-idempotence re-runs.
  • Noticed, not changed here: the runner removes its bundle on the process exit event, which does not fire under bun test, so a test that calls runScenario there leaves its bundle directory behind, and no test on main does.
  • Slice command: bun test/e2e/run.ts --sections branches,environments,repository, the three sections that declare the 14 GraphQL operations.
  • Corpus: 395/395 passed in bun test/e2e/run.ts --sections all, 394 scenarios plus the corpus witness line.
  • Fuzz: 25/25 iterations ok in FUZZ_SEED=20261111 bun test/e2e/fuzz.ts --iterations 25.
  • Where the corpus and the fuzz ran: on this change merged with main at 08b057b, without the equality rule.
  • With the equality rule: the unit tests and the slice ran, and CI runs the corpus and the fuzz on every push.
  • One parse: the request check hands its prepared execution args to the answer check, so each data answer costs one completion on the schema object already built.
  • Files: test/e2e/mock/{graphql-answer.ts,graphql-answer.test.ts,graphql-request.ts,routes.ts}.
  • Published surface: git diff --stat origin/main...HEAD -- src prints nothing.
name: wildcard-null-enforce-admins
settings:
  branches:
    - name: "release/*"
      protection:
        enforce_admins: null
live_state:
  branch_protection_rules:
    - pattern: "release/*"
      isAdminEnforced: true
token_permissions:
  administration: write
  contents: read
expect:
  exit_code: 0
  result: applied
  mutations:
    - "GRAPHQL UpdateBranchProtectionRule"
  fixpoint: converges
name: wildcard-null-linear-history
settings:
  branches:
    - name: "release/*"
      protection:
        required_linear_history: null
live_state:
  branch_protection_rules:
    - pattern: "release/*"
      requiresLinearHistory: true
token_permissions:
  administration: write
  contents: read
expect:
  exit_code: 0
  fixpoint: converges
$ bun test/e2e/run.ts --scenario wildcard-null-linear-history | sed -n '3,/passed$/p'
  FAIL  wildcard-null-linear-history
          convergence: rerun exited 1, expected 0
          convergence: mock violations:
            GraphQL BranchProtectionRules was answered off GitHub's schema by the mock handler "branches.rulesQuery": data.repository.branchProtectionRules.nodes.0.requiresLinearHistory: Cannot return null for non-nullable field BranchProtectionRule.requiresLinearHistory.

0/1 passed

test is a hidden type: this should not be the last merge before a release.

The e2e mock refused a GraphQL request GitHub's schema refuses, but its answers went back to the product as the handler built them. A handler that had stored a null answered it in a Boolean! field, and the two rule mutations answered the whole rule node where their documents select four fields.

The response guard now completes a handler's data with graphql-js under the request's own document, on the execution args the request check already prepared, so nothing is parsed twice. The product receives the completed data: the selection and nothing beside it, with null under a selected nullable key the handler left out. Data that does not complete is a violation naming the operation, the handler, the response path, and graphql-js's message. It is answered under the operation's own log entry, so a read still counts as a read.

graphql-request.ts exports acceptedGraphqlRequest(), and graphqlRequestErrors() wraps it for the section suites' fake, which is unchanged.

Not caught: a value graphql-js coerces (1 for a Boolean, "3" for an Int), and a stored off-schema value that no later request selects.
Copilot AI balanced review requested due to automatic review settings November 12, 2026 10:05
@github-actions

Copy link
Copy Markdown
Contributor

File size check

0 over a hard cap (fails), 6 warning(s).

File Size Tier Cap
src/engine/diff.ts:56 11 comment lines warn 10
src/github/api-error.ts:9 11 comment lines warn 10
src/problem.ts:73 18 comment lines warn 10
src/sections/contract/errors.ts:18 11 comment lines warn 10
src/sections/contract/module.ts:48 12 comment lines warn 10
src/sections/shared/list/decl.ts:300 14 comment lines warn 10

Split the file, wrap the line, shorten or exempt the comment, or list the path in .file-size-allow.local with a # reason.

5 managed file(s) skipped; repo-platform owns them.

@github-actions

Copy link
Copy Markdown
Contributor

Member shape check

22 finding(s).

File Line Rule
src/action/io.ts 24 one-line-comment
src/cli/actions.ts 12 one-line-comment
src/github/api.ts 55 one-line-comment
src/sections/branches/schema.ts 246 all-or-none
src/sections/branches/schema.ts 267 all-or-none
src/sections/branches/schema.ts 405 constant-spacing
src/sections/branches/schema.ts 444 one-line-comment
src/sections/environments/schema.ts 46 one-line-comment
src/sections/environments/schema.ts 108 one-line-comment
src/sections/pages/index.ts 71 one-line-comment
test/cli/actions.test.ts 45 one-line-comment
test/scripts/release-pipeline-fixture.test.ts 25 one-line-comment
test/scripts/release-pipeline-fixture.ts 53 one-line-comment
test/sections/contract/validate-hook.test.ts 80 all-or-none
test/sections/teams/teams.test.ts 20 all-or-none
test/sections/teams/teams.test.ts 23 all-or-none
test/sections/teams/teams.test.ts 24 all-or-none
test/sections/teams/teams.test.ts 25 all-or-none
test/sections/teams/teams.test.ts 26 all-or-none
test/sections/teams/teams.test.ts 28 all-or-none
test/sections/teams/teams.test.ts 31 all-or-none
test/sections/teams/teams.test.ts 34 constant-spacing

Add the blank lines, make the comment one line directly above the item, comment every field or none, or list the path in .member-shape-allow.local with a # reason.

4 generated file(s) skipped.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Approval recommended

No active review issues were identified in the GraphQL mock response-validation changes.

No open issues 🎉

What changed in this PR

This PR checks the end-to-end GraphQL mock's data answers against GitHub's schema, making defects such as the null-response issue highlighted by #599 easier to diagnose. Production code remains unchanged.

Changes:

  • Reuses prepared request execution arguments to complete handler data through graphql-js.
  • Returns only selected fields and reports completion failures as mock violations while preserving read/write classification.
  • Adds unit, pipeline, and scenario-level tests for response shaping and invalid answers.
File Description
test/​e2e/​mock/​routes.ts Integrates response completion and violation reporting before response corruption.
test/​e2e/​mock/​graphql-request.ts Exposes prepared execution arguments while retaining the existing error-checking helper.
test/​e2e/​mock/​graphql-answer.ts Completes handler data against the request selection and formats schema errors with response paths.
test/​e2e/​mock/​graphql-answer.test.ts Tests invalid data, selection shaping, logging, and scenario failure behavior.

Review effort: Balanced

The control that a mutant handler fails a run called the e2e runner's runScenario from a bun test. The runner removes the bundle it builds on the process exit event, which does not fire under bun test, so every test run left a bundle directory in the temp dir, and the unit-test job came to depend on a node binary.

The control now runs the product's own code in-process through runSingle against the mock server. The handlers as they are give "clean" and no violation. A mutant branches.rulesQuery handler gives "failed" and the violation on the mock's list, the list the runner fails a scenario on. On a pipeline without the answer check that run fails too, on the product's own parse, so only the violation tells the two apart.
Copilot AI balanced review requested due to automatic review settings November 12, 2026 10:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Approval recommended

No active review issues remain for the GraphQL mock response-completion changes.

No open issues 🎉

Review effort: Balanced

graphql-js completes some answers GitHub's schema could never give without an error. A non-object at an object position becomes an object of nulls, and a leaf is coerced: true in an Int field to 1, 5 in an ID to "5". Such an answer reached the product with no violation.

After a completion with no error, the completed data must now equal the handler's data at every selected position: an object for an object, a list of the same length for a list, a leaf by ===. The one allowed difference is null under a nullable key the handler left out. Any other difference is a mock violation naming the path and both values.

The file headers no longer say every scenario passed on an answer off the schema. Where the product parses the field, the run already failed, with the product's own error.
Copilot AI balanced review requested due to automatic review settings November 12, 2026 13:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Approval recommended

The review reports no active issues with the schema-backed GraphQL mock response validation changes.

No open issues 🎉

Review effort: Balanced

@Vivswan
Vivswan marked this pull request as ready for review November 12, 2026 13:37
@Vivswan
Vivswan merged commit 03b73ca into main Nov 12, 2026
30 checks passed
@Vivswan
Vivswan deleted the test/e2e-graphql-mock-answers branch November 12, 2026 16:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants