Skip to content

Conversation

@eventualbuddha
Copy link
Collaborator

We currently generate votes and mark ballots accordingly, then sum up those votes and compare the totals against the totals of all the CVRs. This ensures that the totals are correct for the given marking patterns, but it doesn't protect against candidate bubbles being flipped or reordered if both candidates got the same total.

As a first step in protecting against that case, this change makes VxQA produce PROOF ballots, i.e. copies of the ballots annotated to aid in validating their correctness. They have the candidate/contest option associated with the bubble shown with labels in a green box, the locations we expect the bubbles to be marked with a red X, and the area we inspect for write-in marks in a tan box.

The implementation is based on a previous version that was only ever used on a New Hampshire-specific branch: votingworks/vxsuite#5267. It's modified a bit for style and to suit the slightly different tooling in vx-qa.

Tests pull in jest-image-snapshot to do PDF snapshotting in a similar manner to how vxsuite does it.

Added: Ballot Proof Gallery (screenshot)

Screenshot_vxsuite_2026-02-10_14:15:17

Example PROOF PDF:

PROOF-ballot-2_en-s3y4ua5am2y7-official-absentee.pdf

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds “PROOF” ballot PDFs to VxQA runs and exposes them in the generated HTML report to help visually verify bubble-to-contest/option mapping (and catch issues like candidate flip/reorder when totals match).

Changes:

  • Generate PROOF ballot PDFs during runQAWorkflow and include them in the report output directory.
  • Update HTML report generation to render a “Ballot Proof Gallery” pairing each base ballot PDF with its PROOF counterpart.
  • Add PDF snapshot testing utilities (Vitest + jest-image-snapshot) and snapshot fixtures for proof ballot rendering.

Reviewed changes

Copilot reviewed 9 out of 16 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
vitest.config.ts Adds a Vitest setup file to register custom matchers.
src/test/setup.ts Extends Vitest expect with toMatchImageSnapshot.
src/test/pdf-snapshot.ts Adds a helper to render PDF pages to images and snapshot-test them.
src/report/pdf-thumbnail.ts Makes PDF thumbnail scale configurable.
src/report/html-generator.ts Builds base/proof ballot pairs and renders a new “Ballot Proof Gallery”.
src/cli/config-runner.ts Generates PROOF-*.pdf alongside each base ballot PDF.
src/ballots/proof-ballot.ts Implements proof ballot annotation overlay generation with pdf-lib.
src/ballots/proof-ballot.test.ts Adds unit + fixture-based snapshot tests for proof ballots.
src/ballots/image_snapshots/* Adds snapshot PNGs for proof ballot fixture rendering.
package.json Adds dev deps for image snapshot testing.
pnpm-lock.yaml Locks new snapshot-testing dependencies.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 352 to 357
describe('generateProofBallot with real election fixture', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'vx-qa-proof-test-'));
afterAll(() => rm(tmp, { recursive: true, force: true }));

const { electionPackage } = await loadElectionPackage(FIXTURE_PATH, tmp);
const { election } = electionPackage.electionDefinition;
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

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

describe blocks should be synchronous in Vitest. Using an async callback here means the awaited setup runs outside Vitest's normal suite-definition phase and can lead to tests not being registered or running in an unexpected order. Move the async setup (mkdtemp/loadElectionPackage/fixture ballot lookup) into beforeAll (or beforeEach) inside a synchronous describe, and store the loaded election/ballots in variables for the tests to use.

Copilot uses AI. Check for mistakes.
Comment on lines +347 to +350
const FIXTURE_PATH = join(
import.meta.dirname,
'../../test-fixtures/election-package-and-ballots-e71c80e-c4446e7.zip',
);
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

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

import.meta.dirname is only available in newer Node versions, but this repo's engines.node allows any >=20.0.0. To avoid test failures on Node 20.x versions that don't support it, derive the directory from import.meta.url (via fileURLToPath(new URL('.', import.meta.url))) or tighten the Node engine range to a version that guarantees import.meta.dirname.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I believe this will be set up by vitest.

Comment on lines 78 to 83
async function makeBallotGalleryThumbnail(
file: (typeof ballotFiles)[number],
): Promise<string | null> {
return file.name.endsWith('.pdf')
? await generatePdfThumbnail(file.path, { scale: 2 })
: `data:image/png;base64,${await readFileAsBase64(file.path)}`;
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

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

Generating ballot gallery thumbnails for PDFs at scale: 2 can be significantly more CPU/memory intensive than the previous thumbnail behavior, especially if there are many ballots. Consider using a smaller default scale (or making it configurable via CLI/config) so report generation stays responsive while still being readable.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

For the number of ballots we have right now, this is likely to be acceptable. A lot of this is going to be untenable if our ballot count goes beyond ~30 or so.

Overlay contest/candidate labels on each ballot PDF so a human can
visually verify that bubble positions are correctly mapped. Proof PDFs
are saved alongside base ballots and now appear in the HTML report's
new Ballot Gallery section.
Add jest-image-snapshot with a lightweight helper that renders PDF
pages via pdf-to-img and compares them pixel-by-pixel, similar to
vxsuite's toMatchPdfSnapshot.
Export pure geometry/label functions for direct unit testing.
Add PDF snapshot test that renders a synthetic proof ballot and
compares against stored baseline images.
Filter gallery to ballots that have a PROOF- counterpart, excluding
marked/scanned variants. Display each pair side-by-side for easy
comparison. Render gallery thumbnails at 2x scale for readability.
Replace synthetic snapshot test with fixture-based tests that generate
proof ballots from the real election package, covering both ballot
styles (2-page and 4-page).
@eventualbuddha eventualbuddha requested review from jonahkagan and removed request for jonahkagan February 10, 2026 23:30
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.

1 participant