diff --git a/.circleci/config.yml b/.circleci/config.yml index a30638f..05d9bba 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -13,11 +13,14 @@ parameters: election_id: type: string default: '' + vxsuite_version: + type: string + default: '' executors: nodejs: docker: - - image: votingworks/cimg-debian12:4.2.0 + - image: votingworks/cimg-debian12:4.5.0 auth: username: $VX_DOCKER_USERNAME password: $VX_DOCKER_PASSWORD @@ -33,8 +36,10 @@ commands: - v1-pnpm-{{ checksum "pnpm-lock.yaml" }} - v1-pnpm- - run: - name: Install Latest pnpm - command: npm install -g pnpm@latest + name: Install pnpm + # Pinned to a Node 20-compatible release: pnpm >= 11 requires + # Node >= 22.13 (it uses node:sqlite), but the CI image runs Node 20. + command: npm install -g pnpm@10.34.1 - run: name: Install Dependencies command: pnpm install --frozen-lockfile @@ -60,9 +65,22 @@ jobs: name: Test command: pnpm test --run - test-qa-e71c80e: + # Runs the pinned QA fixture for a single VxSuite version. The VxSuite repo is + # cloned/built once per (version, patch) and cached; the cache key includes + # the version and a hash of that version's patch file. + test-qa: executor: nodejs resource_class: xlarge + parameters: + version: + type: string + config: + description: >- + Config file basename (vx-qa-config-.json). Defaults to the + vxsuite version, but can differ so multiple configs (e.g. a primary + election) can share one vxsuite version/patch/cache. + type: string + default: '' steps: - checkout-and-install - run: @@ -76,30 +94,33 @@ jobs: - run: name: Compute VxSuite Cache Key command: | - # Extract the vxsuite ref from the config file - VXSUITE_REF=$(node -e "console.log(require('./test-fixtures/vx-qa-config-e71c80e.json').vxsuite.ref)") - echo "VxSuite ref: $VXSUITE_REF" + VERSION="<< parameters.version >>" + echo "VxSuite version: $VERSION" - # Compute hash of the patch file - PATCH_HASH=$(sha256sum vxsuite.patch | cut -d' ' -f1) + # Hash the version-specific patch file so cache invalidates on change + PATCH_HASH=$(sha256sum "vxsuite-${VERSION}.patch" | cut -d' ' -f1) echo "Patch hash: $PATCH_HASH" - # Create cache key file - echo "${VXSUITE_REF}-${PATCH_HASH}" > /tmp/vxsuite-cache-key.txt + echo "${VERSION}-${PATCH_HASH}" > /tmp/vxsuite-cache-key.txt cat /tmp/vxsuite-cache-key.txt - restore_cache: name: Restore VxSuite Repository Cache keys: - - v1-vxsuite-{{ checksum "/tmp/vxsuite-cache-key.txt" }} + - v4-vxsuite-{{ checksum "/tmp/vxsuite-cache-key.txt" }} - run: - name: Run QA Test - e71c80e + name: Run QA Test - << parameters.version >> command: | + CONFIG="<< parameters.config >>" + if [ -z "$CONFIG" ]; then + CONFIG="<< parameters.version >>" + fi + echo "Config: vx-qa-config-${CONFIG}.json" cd test-fixtures - node ../dist/index.js run --config vx-qa-config-e71c80e.json + DEBUG=1 node ../dist/index.js run --config "vx-qa-config-${CONFIG}.json" no_output_timeout: 30m - save_cache: name: Save VxSuite Repository Cache - key: v1-vxsuite-{{ checksum "/tmp/vxsuite-cache-key.txt" }} + key: v4-vxsuite-{{ checksum "/tmp/vxsuite-cache-key.txt" }} paths: - ~/.vx-qa/vxsuite when: always @@ -122,33 +143,42 @@ jobs: - run: name: Compute VxSuite Cache Key command: | - # Extract the vxsuite ref from the config file - VXSUITE_REF=$(node -e "console.log(require('./test-fixtures/vx-qa-config-vxdesign.json').vxsuite.ref)") - echo "VxSuite ref: $VXSUITE_REF" + # Version comes from the pipeline parameter when set, otherwise the + # config file's default. + VERSION="<< pipeline.parameters.vxsuite_version >>" + if [ -z "$VERSION" ]; then + VERSION=$(node -e "console.log(require('./test-fixtures/vx-qa-config-vxdesign.json').vxsuite.version)") + fi + echo "VxSuite version: $VERSION" - # Compute hash of the patch file - PATCH_HASH=$(sha256sum vxsuite.patch | cut -d' ' -f1) + PATCH_HASH=$(sha256sum "vxsuite-${VERSION}.patch" | cut -d' ' -f1) echo "Patch hash: $PATCH_HASH" - # Create cache key file - echo "${VXSUITE_REF}-${PATCH_HASH}" > /tmp/vxsuite-cache-key.txt + echo "${VERSION}-${PATCH_HASH}" > /tmp/vxsuite-cache-key.txt cat /tmp/vxsuite-cache-key.txt - restore_cache: name: Restore VxSuite Repository Cache keys: - - v1-vxsuite-{{ checksum "/tmp/vxsuite-cache-key.txt" }} + - v4-vxsuite-{{ checksum "/tmp/vxsuite-cache-key.txt" }} - run: name: Run QA - VxDesign Export command: | + # Only pass --vxsuite-version when the pipeline provided one; + # otherwise fall back to the config file's version. + VERSION_ARG="" + if [ -n "<< pipeline.parameters.vxsuite_version >>" ]; then + VERSION_ARG="--vxsuite-version << pipeline.parameters.vxsuite_version >>" + fi node dist/index.js run \ --config test-fixtures/vx-qa-config-vxdesign.json \ --election "<< pipeline.parameters.export_package_url >>" \ --webhook-url "<< pipeline.parameters.webhook_url >>" \ - --webhook-secret "$CIRCLECI_WEBHOOK_SECRET" + --webhook-secret "$CIRCLECI_WEBHOOK_SECRET" \ + $VERSION_ARG no_output_timeout: 30m - save_cache: name: Save VxSuite Repository Cache - key: v1-vxsuite-{{ checksum "/tmp/vxsuite-cache-key.txt" }} + key: v4-vxsuite-{{ checksum "/tmp/vxsuite-cache-key.txt" }} paths: - ~/.vx-qa/vxsuite when: always @@ -161,7 +191,28 @@ workflows: equal: ['', << pipeline.parameters.export_package_url >>] jobs: - test-unit - - test-qa-e71c80e + - test-qa: + name: test-qa-v4.0 + version: 'v4.0' + - test-qa: + name: test-qa-v4.1 + version: 'v4.1' + - test-qa: + name: test-qa-v4.0-primary + version: 'v4.0' + config: 'v4.0-primary' + - test-qa: + name: test-qa-v4.0-overvote + version: 'v4.0' + config: 'v4.0-overvote' + - test-qa: + name: test-qa-v4.1-primary + version: 'v4.1' + config: 'v4.1-primary' + - test-qa: + name: test-qa-v4.1-overvote + version: 'v4.1' + config: 'v4.1-overvote' qa-from-vxdesign: when: diff --git a/.node-version b/.node-version index 8ce7030..5bd6811 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -20.16.0 +20.19.0 diff --git a/README.md b/README.md index dfa7e33..3a0f98a 100644 --- a/README.md +++ b/README.md @@ -38,13 +38,12 @@ pnpm start run --config my-config.json pnpm start run [options] Options: - -c, --config Path to configuration file - -s, --save-config Save interactive selections to config file - -o, --output Override output directory - -r, --ref Override VxSuite tag/branch/rev - -e, --election Override election source path - --headless Run browser in headless mode (default) - --no-headless Run browser in headed mode for debugging + -c, --config Path to configuration file + -o, --output Override output directory + --vxsuite-version Override VxSuite version (e.g. v4.0, v4.1) + -e, --election Override election source path + --headless Run browser in headless mode (default) + --no-headless Run browser in headed mode for debugging ``` ## Configuration @@ -55,7 +54,7 @@ Example `vx-qa-config.json`: { "vxsuite": { "repoPath": "~/.vx-qa/vxsuite", - "ref": "v4.0.4" + "version": "v4.0" }, "election": { "source": "./election-package-and-ballots.zip" @@ -66,6 +65,17 @@ Example `vx-qa-config.json`: } ``` +`vxsuite.version` selects which VxSuite release to run against. Values match +VxSuite's own `SoftwareVersion` identifiers: + +| version | git ref | patch | +| -------- | -------------- | -------------------- | +| `"v4.0"` | `v4.0.7` | `vxsuite-v4.0.patch` | +| `"v4.1"` | `v4.1.0-alpha` | `vxsuite-v4.1.patch` | + +The git ref and patch file are derived from the version; you don't set them +directly. Older versions (≤ v4.0.4) are not supported. + ## Output Each run creates a timestamped directory with an HTML report and the supporting files, @@ -98,10 +108,15 @@ pnpm test The project runs automated QA tests in CircleCI using test fixtures in the `test-fixtures/` directory. The CI workflow: 1. Runs unit tests with `pnpm test` -2. Executes a full QA test run using the `vx-qa-config-e71c80e.json` configuration +2. Executes a full QA test run per supported VxSuite version, using the + `vx-qa-config-.json` fixtures (e.g. `vx-qa-config-v4.0.json`, + `vx-qa-config-v4.1.json`). Each version clones/builds its own pinned + VxSuite ref and applies that version's patch. 3. Stores output artifacts for review -Test configurations and election packages can be added to `test-fixtures/` to expand CI coverage. +Each version's fixture references an election package exported from that same +VxSuite version (v4.1's package must carry the newer `ballotPositions` ballot +model). Add configurations and packages to `test-fixtures/` to expand coverage. ## Architecture diff --git a/package.json b/package.json index bb40303..06ba1fd 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "oxlint-tsgolint": "^0.9.1", "vitest": "^4.0.16" }, + "packageManager": "pnpm@10.34.1", "engines": { "node": ">=20.0.0" } diff --git a/src/apps/env-config.ts b/src/apps/env-config.ts index 83cefc5..81eeee2 100644 --- a/src/apps/env-config.ts +++ b/src/apps/env-config.ts @@ -2,8 +2,6 @@ * Environment configuration for mock hardware */ -import assert from 'node:assert'; - /** * Environment variables to enable mock hardware */ @@ -28,6 +26,13 @@ export const MOCK_ENV_VARS: Record = { REACT_APP_VX_ENABLE_ALL_ZERO_SMARTCARD_PIN_GENERATION: 'TRUE', }; +/** + * NODE_ENV the VxSuite apps run under. Also determines the mock-state directory + * VxSuite uses: `/.mock-state//` (see getMockStateRootDir in + * libs/utils). Keep in sync with the value passed in getMockEnvironment. + */ +export const MOCK_NODE_ENV = 'development'; + /** * Get environment variables for running VxSuite apps with mocks */ @@ -35,7 +40,7 @@ export function getMockEnvironment(): NodeJS.ProcessEnv { return { ...process.env, ...MOCK_ENV_VARS, - NODE_ENV: 'development', + NODE_ENV: MOCK_NODE_ENV, }; } @@ -53,22 +58,26 @@ export const MACHINE_TYPES = { export type MachineType = keyof typeof MACHINE_TYPES; /** - * Default ports used by VxSuite apps + * Ports used by VxSuite apps in dev mode. The frontend (Vite) serves on + * FRONTEND_PORT and each app's backend runs on FRONTEND_PORT + 1 (see + * apps/*\/backend/src/globals.ts: `PORT = Number(FRONTEND_PORT || 3000) + 1`). + * Only one app runs at a time, so a single frontend/backend pair covers every + * machine type. */ +export const FRONTEND_PORT = 3000; +export const BACKEND_PORT = FRONTEND_PORT + 1; + +/** Ports to watch/clean up when starting or stopping apps. */ export const APP_PORTS = { - frontend: 3000, - mark: 3001, - scan: 3002, - 'mark-scan': 3003, - admin: 3004, - 'central-scan': 3005, + frontend: FRONTEND_PORT, + backend: BACKEND_PORT, } as const; /** - * Get backend port for a specific machine type + * Get the backend port for a machine type. Every VxSuite app uses + * FRONTEND_PORT + 1 for its backend and only one runs at a time, so the machine + * type doesn't affect the port. */ -export function getBackendPort(machineType: MachineType): number { - const port = APP_PORTS[machineType]; - assert(port !== undefined, `No port for machine type '${machineType}'`); - return port; +export function getBackendPort(_machineType: MachineType): number { + return BACKEND_PORT; } diff --git a/src/apps/orchestrator.ts b/src/apps/orchestrator.ts index 7bf7edc..560945f 100644 --- a/src/apps/orchestrator.ts +++ b/src/apps/orchestrator.ts @@ -11,19 +11,41 @@ import { waitForDevDock } from '../mock-hardware/client.js'; import { promisify } from 'node:util'; import { appendFileSync } from 'node:fs'; import { join } from 'node:path'; +import net from 'node:net'; /** - * Check if a port is free (not in use) + * Check if a port is free (not in use). Uses a TCP connection attempt rather + * than `lsof`, which isn't guaranteed to be installed in CI images (and whose + * absence previously made this always report "free"). */ -async function isPortFree(port: number): Promise { - const execFileAsync = promisify(execFile); - try { - const { stdout } = await execFileAsync('lsof', [`-ti:${port}`]); - return stdout.trim().length === 0; - } catch { - // If lsof errors (e.g., no process found), port is free - return true; +async function isPortFree(port: number, host = '127.0.0.1'): Promise { + return new Promise((resolve) => { + const socket = new net.Socket(); + const finish = (free: boolean) => { + socket.destroy(); + resolve(free); + }; + socket.setTimeout(1000); + socket.once('connect', () => finish(false)); // something is listening + socket.once('timeout', () => finish(true)); + socket.once('error', () => finish(true)); // connection refused => free + socket.connect(port, host); + }); +} + +/** + * Wait until all the given ports are free (up to `timeout` ms). + */ +async function waitForPortsFree(ports: number[], timeout = 15000): Promise { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const free = await Promise.all(ports.map((port) => isPortFree(port))); + if (free.every(Boolean)) { + return true; + } + await sleep(500); } + return false; } export interface AppOrchestrator { @@ -80,6 +102,19 @@ export function createAppOrchestrator(repoPath: string, logDir?: string): AppOrc const spinner = logger.spinner(`Starting ${app} app...`); try { + // Ensure a clean port slate. A previous app's run-dev process tree + // (Vite, backend, watchers) can linger and hold ports 3000/3001, + // causing the new app's Vite to bind a different port so navigation to + // :3000 is refused. Kill anything on our ports and wait until they are + // actually free before starting. + await ensureNoAppsRunning(); + const portsFree = await waitForPortsFree([APP_PORTS.frontend, getBackendPort(app)]); + if (!portsFree) { + logger.warn( + `Ports ${APP_PORTS.frontend}/${getBackendPort(app)} still busy before starting ${app}; continuing anyway`, + ); + } + // Start the app using pnpm run-dev const env = getMockEnvironment(); @@ -246,14 +281,16 @@ export function createAppOrchestrator(repoPath: string, logDir?: string): AppOrc state.appLogPath = null; state.appOutput = []; - // Wait for ports to be released by checking they're actually free - const backendPort = getBackendPort(appName); - const maxWaitTime = 5000; // 5 seconds max + // Wait for both the frontend and backend ports to be released, so the + // next app can bind them cleanly (otherwise its Vite may pick a + // different port and navigation to :3000 is refused). + const portsToFree = [APP_PORTS.frontend, getBackendPort(appName)]; + const maxWaitTime = 10000; // 10 seconds max const startTime = Date.now(); while (Date.now() - startTime < maxWaitTime) { - const portFree = await isPortFree(backendPort); - if (portFree) { + const free = await Promise.all(portsToFree.map((port) => isPortFree(port))); + if (free.every(Boolean)) { break; } await sleep(200); diff --git a/src/automation/admin-tally-workflow.ts b/src/automation/admin-tally-workflow.ts index 0b527c5..18edc4f 100644 --- a/src/automation/admin-tally-workflow.ts +++ b/src/automation/admin-tally-workflow.ts @@ -12,6 +12,7 @@ import { clickButtonWithDebug, clickTextInApp, toggleDevDock, + debugPageState, } from './browser.js'; import { loadCollection, type StepCollector, type ArtifactCollector } from '../report/artifacts.js'; import { ArtifactCollection, StepOutput, ValidationResult } from '../config/types.js'; @@ -225,6 +226,17 @@ async function processBallotStyle( } } + // In primary elections, VxAdmin labels each ballot-style option with the + // party appended (e.g. "Bedford - Democratic"). Without this suffix the + // dropdown option never matches, the style is never selected, and the + // Voting Method dropdown stays disabled. + if (ballotStyleInfo?.partyId) { + const party = election.parties?.find((p) => p.id === ballotStyleInfo.partyId); + if (party) { + displayName = `${displayName} - ${party.name}`; + } + } + logger.debug(`Looking for ballot style option containing "${displayName}"`); // Click on the text - try multiple strategies @@ -550,8 +562,21 @@ async function loadCvrs( await stepCollector.captureScreenshot('admin-tally-before-load-cvrs', 'Before click Load CVRs'); - // Click the "Load CVRs" button - await page.getByText('Load CVRs').click(); + // Open the CVR import modal. v4.0 labels the opener "Load CVRs" (matched by + // text, as originally). v4.1's rewritten CVR screen uses an icon button + // labeled "Load" whose icon pollutes the accessible name, so target its + // explicit CSS class instead of a role-name. + try { + const loadCvrsByText = page.getByText('Load CVRs'); + if ((await loadCvrsByText.count()) > 0) { + await loadCvrsByText.first().click({ timeout: 15000 }); + } else { + await page.locator('.LoadButton').first().click({ timeout: 15000 }); + } + } catch (error) { + await debugPageState(page, 'Failed to open CVR import modal', outputDir); + throw error; + } await page.waitForTimeout(1000); await stepCollector.captureScreenshot('admin-tally-load-cvr-dialog', 'Load CVR dialog'); @@ -950,7 +975,11 @@ export async function validateTallyResults( const selectionId = fields[selectionIdIndex]; const votes = parseInt(fields[totalVotesColumnIndex] || '0', 10); - if (!isNaN(votes) && selectionId !== 'overvotes' && selectionId !== 'undervotes') { + // Skip non-candidate metric rows. v4.1's tally CSV adds a per-contest + // 'ballots-cast' selection alongside 'overvotes'/'undervotes'; none are + // candidate votes. + const metricSelectionIds = ['overvotes', 'undervotes', 'ballots-cast']; + if (!isNaN(votes) && !metricSelectionIds.includes(selectionId)) { if (!actualVotes.has(contestId)) { actualVotes.set(contestId, new Map()); } diff --git a/src/automation/browser.ts b/src/automation/browser.ts index 54a8fa9..85980c7 100644 --- a/src/automation/browser.ts +++ b/src/automation/browser.ts @@ -58,12 +58,31 @@ export async function createBrowserSession(options: BrowserOptions = {}): Promis } /** - * Navigate to the app's main page + * Navigate to the app's main page. + * + * Retries on connection errors: after an app (re)starts, the frontend port can + * accept TCP before Vite is actually serving, so the first navigation may be + * refused/reset. */ -export async function navigateToApp(page: Page): Promise { +export async function navigateToApp(page: Page, attempts = 10): Promise { const url = `http://localhost:${APP_PORTS.frontend}/`; logger.debug(`Navigating to ${url}`); - await page.goto(url); + + for (let attempt = 1; ; attempt += 1) { + try { + await page.goto(url); + return; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const isConnectionError = + /ERR_CONNECTION_REFUSED|ERR_CONNECTION_RESET|ERR_EMPTY_RESPONSE|NS_ERROR/i.test(message); + if (!isConnectionError || attempt >= attempts) { + throw error; + } + logger.debug(`Navigation attempt ${attempt} failed (${message}); retrying...`); + await page.waitForTimeout(1000); + } + } } /** diff --git a/src/automation/scan-workflow.ts b/src/automation/scan-workflow.ts index 2fabffb..8d47cf2 100644 --- a/src/automation/scan-workflow.ts +++ b/src/automation/scan-workflow.ts @@ -20,6 +20,7 @@ import type { StepCollector, ArtifactCollector } from '../report/artifacts.js'; import { basename, join } from 'node:path'; import { createMockScannerController } from '../mock-hardware/scanner.js'; import { generateMarkedBallotForPattern } from '../ballots/ballot-marker.js'; +import { MOCK_NODE_ENV } from '../apps/env-config.js'; import { BallotMode, BallotType, @@ -67,8 +68,9 @@ export async function runScanWorkflow( const usbController = createMockUsbController({ dataPath }); const scannerController = createMockScannerController(); - // Track existing thermal printer files to avoid duplicates - const printerWorkspace = join(repoPath, 'libs/fujitsu-thermal-printer/dev-workspace'); + // Track existing thermal printer files to avoid duplicates. VxSuite writes + // mock printer output to /.mock-state//prints. + const printerWorkspace = join(repoPath, '.mock-state', MOCK_NODE_ENV); const existingPrinterFiles = new Set(); try { const printsDir = join(printerWorkspace, 'prints'); @@ -196,12 +198,34 @@ export async function runScanWorkflow( 'Close the polls and print results reports', ); - const pollWorkerCardForClosingPolls = await insertPollWorkerCard(page, electionPath); - await waitForTextInAppWithDebug(page, 'Do you want to close the polls?', { - timeout: 10000, - outputDir, - label: 'Waiting for close polls confirmation prompt', - }); + // Insert the poll worker card to bring up the close-polls prompt. The mock + // card insertion can occasionally race with the app settling after the last + // scanned ballot, leaving the app on the voter screen, so retry the insertion + // a few times before giving up. + const closePollsPrompt = 'Do you want to close the polls?'; + let pollWorkerCardForClosingPolls = await insertPollWorkerCard(page, electionPath); + for (let attempt = 1; ; attempt++) { + try { + await waitForTextInApp(page, closePollsPrompt, { timeout: 10000 }); + break; + } catch { + if (attempt >= 3) { + // Capture debug state on the final attempt, then fail. + await waitForTextInAppWithDebug(page, closePollsPrompt, { + timeout: 5000, + outputDir, + label: 'Waiting for close polls confirmation prompt', + }); + break; + } + logger.warn( + `Close-polls prompt not shown (attempt ${attempt}); re-inserting poll worker card`, + ); + await pollWorkerCardForClosingPolls.removeCard(); + await page.waitForTimeout(1000); + pollWorkerCardForClosingPolls = await insertPollWorkerCard(page, electionPath); + } + } await clickButtonWithDebug(page, 'Close Polls', { timeout: 10000, @@ -367,6 +391,18 @@ async function scanBallot( .filter(([, votes]) => votes.length > 0), ); + // A contest marked beyond its seat count is an overvote: the scanner counts + // no selections for it (only an overvote). Drop such contests so the + // recorded votes reflect what is actually counted — this matters when an + // overvoted ballot is cast (accepted) rather than returned. + for (const [contestId, contestVotes] of Object.entries(votesForSheet)) { + const contest = election.contests.find((c) => c.id === contestId); + const seats = contest?.type === 'candidate' ? contest.seats : 1; + if (contestVotes.length > seats) { + delete votesForSheet[contestId]; + } + } + // Convert votes to IDs for validation (handles both Candidate objects and string IDs) const votesAsIds = votesWithOnlyIds(votesForSheet); @@ -435,8 +471,53 @@ async function scanBallot( const messageText = await message.innerText(); logger.debug(`Message after sheet ${sheetIndex + 1}: ${messageText}`); - // If rejected, handle immediately and stop scanning more sheets - if (messageText !== 'Your ballot was counted!' && /ballot|wrong/i.test(messageText)) { + // A ballot the scanner won't count outright presents a review screen + // ("Review Your Ballot") or a wrong-election/precinct rejection. + const needsReview = + messageText !== 'Your ballot was counted!' && /ballot|wrong/i.test(messageText); + + if (needsReview && ballot.expectedAccepted) { + // A castable warning we choose to cast anyway, e.g. an overvote when the + // election allows casting overvotes. Click "Cast Ballot" and confirm the + // ballot is counted. + logger.info( + `Ballot needs review after sheet ${sheetIndex + 1}/${sheetCount}: ${messageText}; casting anyway`, + ); + await stepCollector.captureScreenshot( + `scan-${ballotStyleId}-${markPattern}-sheet-${sheetIndex + 1}-review`, + `Ballot needs review (casting anyway): ${ballotStyleId} ${markPattern}`, + ); + + await page.waitForTimeout(1000); + await page.getByRole('button', { name: 'Cast Ballot' }).click(); + try { + await waitForTextInApp(page, 'Your ballot was counted!'); + } catch (error) { + await stepCollector.captureScreenshot( + 'timeout-cast-ballot', + 'Timeout waiting for ballot to be counted after casting', + ); + throw error; + } + + const screenshot = await stepCollector.captureScreenshot( + `scan-${ballotStyleId}-${markPattern}-sheet-${sheetIndex + 1}`, + `Ballot cast after review: ${ballotStyleId} ${markPattern} (${sheetIndex + 1}/${sheetCount})`, + ); + + await stepCollector.addOutput({ + type: 'scan-result', + label: `Scan Result ${sheetIndex + 1} of ${sheetCount}`, + description: 'Ballot cast after review', + accepted: true, + expected: ballot.expectedAccepted, + screenshotPath: screenshot.path, + ballotStyleId, + ballotMode: ballot.ballotMode, + markPattern, + votes: votesForSheet, + }); + } else if (needsReview) { logger.info(`Ballot rejected after sheet ${sheetIndex + 1}/${sheetCount}: ${messageText}`); const screenshot = await stepCollector.captureScreenshot( diff --git a/src/ballots/election-loader.test.ts b/src/ballots/election-loader.test.ts index 08a2450..c1c0db8 100644 --- a/src/ballots/election-loader.test.ts +++ b/src/ballots/election-loader.test.ts @@ -3,8 +3,18 @@ */ import { describe, test, expect } from 'vitest'; -import { getContestsForBallotStyle } from './election-loader.js'; -import type { Election, CandidateContest, YesNoContest, BallotStyle } from './election-loader.js'; +import { + getContestsForBallotStyle, + normalizeGridLayouts, + normalizeYesNoContests, +} from './election-loader.js'; +import type { + Election, + CandidateContest, + YesNoContest, + BallotStyle, + SheetPositions, +} from './election-loader.js'; /** * Helper to create a test election @@ -217,3 +227,158 @@ describe('getContestsForBallotStyle', () => { expect(result2[0].id).toBe('city-council'); }); }); + +describe('normalizeGridLayouts', () => { + test('leaves existing gridLayouts untouched (v4.0.x format)', () => { + const election = createTestElection( + [{ id: 'bs-1', precincts: ['precinct-1'], districts: ['district-1'] }], + [], + ); + election.gridLayouts = [ + { + ballotStyleId: 'bs-1', + optionBoundsFromTargetMark: { x: 0, y: 0, width: 1, height: 1 }, + gridPositions: [ + { + type: 'option', + sheetNumber: 1, + side: 'front', + column: 2, + row: 3, + contestId: 'c', + optionId: 'o', + }, + ], + }, + ]; + + normalizeGridLayouts(election); + + expect(election.gridLayouts).toHaveLength(1); + expect(election.gridLayouts[0].gridPositions[0]).toMatchObject({ optionId: 'o' }); + }); + + test('derives gridLayouts from ballotPositions (v4.1 format)', () => { + const ballotPositions: SheetPositions[] = [ + [ + // front + [ + { + contestId: 'mayor', + bounds: { row: 0, column: 0, width: 10, height: 10 }, + options: [ + { + type: 'option', + bubbleCenter: { row: 5, column: 4 }, + bounds: { row: 4, column: 3, width: 2, height: 2 }, + optionId: 'alice', + }, + { + type: 'write-in', + bubbleCenter: { row: 7, column: 4 }, + bounds: { row: 6, column: 3, width: 2, height: 2 }, + writeInIndex: 0, + writeInArea: { row: 6, column: 8, width: 12, height: 3 }, + }, + ], + }, + ], + // back + [], + ], + ]; + + const election = createTestElection( + [{ id: 'bs-1', precincts: ['precinct-1'], districts: ['district-1'], ballotPositions }], + [], + ); + + normalizeGridLayouts(election); + + expect(election.gridLayouts).toHaveLength(1); + const layout = election.gridLayouts![0]; + expect(layout.ballotStyleId).toBe('bs-1'); + expect(layout.gridPositions).toHaveLength(2); + + expect(layout.gridPositions[0]).toEqual({ + type: 'option', + sheetNumber: 1, + side: 'front', + column: 4, + row: 5, + contestId: 'mayor', + optionId: 'alice', + }); + + expect(layout.gridPositions[1]).toEqual({ + type: 'write-in', + sheetNumber: 1, + side: 'front', + column: 4, + row: 7, + contestId: 'mayor', + writeInIndex: 0, + // grid rect (row/column/width/height) converted to Rect (x/y/width/height) + writeInArea: { x: 8, y: 6, width: 12, height: 3 }, + }); + }); + + test('is a no-op when neither gridLayouts nor ballotPositions are present', () => { + const election = createTestElection( + [{ id: 'bs-1', precincts: ['precinct-1'], districts: ['district-1'] }], + [], + ); + + normalizeGridLayouts(election); + + expect(election.gridLayouts).toBeUndefined(); + }); +}); + +describe('normalizeYesNoContests', () => { + test('derives yesOption/noOption from a v4.1 options[] array', () => { + const yesno = { + type: 'yesno', + id: 'measure-1', + title: 'Measure 1', + districtId: 'district-1', + options: [ + { id: 'measure-1-yes', label: 'Yes' }, + { id: 'measure-1-no', label: 'No' }, + ], + } as unknown as YesNoContest; + + const election = createTestElection( + [{ id: 'bs-1', precincts: ['precinct-1'], districts: ['district-1'] }], + [yesno], + ); + + normalizeYesNoContests(election); + + const result = election.contests[0] as YesNoContest; + expect(result.yesOption).toEqual({ id: 'measure-1-yes', label: 'Yes' }); + expect(result.noOption).toEqual({ id: 'measure-1-no', label: 'No' }); + }); + + test('leaves v4.0 yesOption/noOption contests untouched', () => { + const yesno: YesNoContest = { + type: 'yesno', + id: 'measure-1', + title: 'Measure 1', + districtId: 'district-1', + yesOption: { id: 'y', label: 'Yes' }, + noOption: { id: 'n', label: 'No' }, + }; + + const election = createTestElection( + [{ id: 'bs-1', precincts: ['precinct-1'], districts: ['district-1'] }], + [yesno], + ); + + normalizeYesNoContests(election); + + const result = election.contests[0] as YesNoContest; + expect(result.yesOption).toEqual({ id: 'y', label: 'Yes' }); + expect(result.noOption).toEqual({ id: 'n', label: 'No' }); + }); +}); diff --git a/src/ballots/election-loader.ts b/src/ballots/election-loader.ts index a517dc5..5227495 100644 --- a/src/ballots/election-loader.ts +++ b/src/ballots/election-loader.ts @@ -42,15 +42,31 @@ export interface CandidateContest { candidates: Candidate[]; allowWriteIns: boolean; districtId: string; + /** + * Present in primary elections: the contest belongs to this party and only + * appears on that party's ballot styles. + */ + partyId?: string; +} + +export interface YesNoOption { + id: string; + label: string; } export interface YesNoContest { type: 'yesno'; id: string; title: string; - yesOption: { id: string; label: string }; - noOption: { id: string; label: string }; + yesOption: YesNoOption; + noOption: YesNoOption; districtId: string; + /** + * v4.1+ represents yes/no options as an ordered `options` array + * ([yes, no, ...additional]) rather than `yesOption`/`noOption`. Normalized + * into `yesOption`/`noOption` on load. + */ + options?: YesNoOption[]; } export type Contest = CandidateContest | YesNoContest; @@ -59,8 +75,69 @@ export interface BallotStyle { id: string; precincts: string[]; districts: string[]; + /** + * Present in primary elections: the party this ballot style is for. Used to + * restrict contests to that party and to label the style (e.g. + * "Bedford - Democratic") in VxAdmin. + */ + partyId?: string; + /** + * Language-independent grouping of ballot styles (e.g. "1-DEM"). The `id` + * additionally encodes the language (e.g. "1-DEM_en"). + */ + groupId?: string; + /** + * v4.1+ carries ballot geometry per ballot style here instead of in a + * top-level `gridLayouts`. Normalized into `Election.gridLayouts` on load. + */ + ballotPositions?: SheetPositions[]; +} + +/** + * Ballot-position model used by v4.1+ election definitions + * (`ballotStyles[].ballotPositions`). Mirrors the shapes in + * libs/types/src/election.ts so we can normalize them into the flat + * grid-position model the rest of the tool uses. + */ +export interface GridPoint { + row: number; + column: number; } +export interface GridRect { + row: number; + column: number; + width: number; + height: number; +} + +export interface OptionPosition { + type: 'option'; + bubbleCenter: GridPoint; + bounds: GridRect; + optionId: string; + partyIds?: string[]; +} + +export interface WriteInPosition { + type: 'write-in'; + bubbleCenter: GridPoint; + bounds: GridRect; + writeInIndex: number; + writeInArea: GridRect; +} + +export type ContestOptionPosition = OptionPosition | WriteInPosition; + +export interface ContestPosition { + contestId: string; + bounds: GridRect; + options: ContestOptionPosition[]; +} + +/** Per-sheet positions: a [front, back] tuple of contest positions. */ +export type SheetPositions = [front: ContestPosition[], back: ContestPosition[]]; + export interface Precinct { id: string; name: string; @@ -164,6 +241,13 @@ async function parseElectionPackageZip(zip: JSZip, sourcePath: string): Promise< const election = JSON.parse(electionData) as Election; const ballotHash = await calculateHash(electionData); + // v4.1+ elections differ from v4.0 in a few shapes the tool reads. Normalize + // them so downstream code can rely on the v4.0-style model regardless of + // version: ballot geometry (`ballotPositions` -> `gridLayouts`) and yes/no + // contests (`options[]` -> `yesOption`/`noOption`). + normalizeGridLayouts(election); + normalizeYesNoContests(election); + // Load metadata if present let metadata: ElectionPackage['metadata']; const metadataFile = zip.file('metadata.json'); @@ -224,6 +308,100 @@ async function parseElectionPackageZip(zip: JSZip, sourcePath: string): Promise< }; } +/** Convert a (column, row) grid rect into the tool's (x, y) Rect. */ +function gridRectToRect(gridRect: GridRect): Rect { + return { + x: gridRect.column, + y: gridRect.row, + width: gridRect.width, + height: gridRect.height, + }; +} + +/** + * Derive top-level `gridLayouts` from `ballotStyles[].ballotPositions` (v4.1+ + * format). No-op when the election already has `gridLayouts` (v4.0.x format) or + * when no ballot positions are present. + */ +export function normalizeGridLayouts(election: Election): void { + if (election.gridLayouts && election.gridLayouts.length > 0) { + return; + } + + const gridLayouts: GridLayout[] = []; + const sides: BallotSide[] = ['front', 'back']; + + for (const ballotStyle of election.ballotStyles) { + if (!ballotStyle.ballotPositions) { + continue; + } + + const gridPositions: GridPosition[] = []; + + ballotStyle.ballotPositions.forEach((sheet, sheetIndex) => { + const sheetNumber = sheetIndex + 1; + + sides.forEach((side, sideIndex) => { + for (const contest of sheet[sideIndex] ?? []) { + for (const option of contest.options) { + const base = { + sheetNumber, + side, + contestId: contest.contestId, + column: option.bubbleCenter.column, + row: option.bubbleCenter.row, + } as const; + + if (option.type === 'write-in') { + gridPositions.push({ + ...base, + type: 'write-in', + writeInIndex: option.writeInIndex, + writeInArea: gridRectToRect(option.writeInArea), + }); + } else { + gridPositions.push({ ...base, type: 'option', optionId: option.optionId }); + } + } + } + }); + }); + + gridLayouts.push({ + ballotStyleId: ballotStyle.id, + // Unused by the QA tool (proof ballots derive bounds from write-in areas); + // vxsuite's marking reads ballotPositions directly. + optionBoundsFromTargetMark: { x: 0, y: 0, width: 0, height: 0 }, + gridPositions, + }); + } + + if (gridLayouts.length > 0) { + election.gridLayouts = gridLayouts; + logger.debug(`Derived ${gridLayouts.length} gridLayouts from ballotPositions`); + } +} + +/** + * Derive `yesOption`/`noOption` from a v4.1+ `options` array on yes/no + * contests. No-op for v4.0 contests that already have `yesOption`/`noOption`. + * Additional options beyond the first two (multi-option ballot measures) are + * left in `options` for callers that need them. + */ +export function normalizeYesNoContests(election: Election): void { + for (const contest of election.contests) { + if (contest.type !== 'yesno') { + continue; + } + + if ((!contest.yesOption || !contest.noOption) && contest.options) { + assert(contest.options.length >= 2, `yes/no contest ${contest.id} has fewer than 2 options`); + contest.yesOption = contest.options[0]; + contest.noOption = contest.options[1]; + } + } +} + /** * Calculate a simple hash for the election data */ @@ -248,7 +426,19 @@ export function getContestsForBallotStyle(election: Election, ballotStyleId: str } const districts = ballotStyle.districts; - return election.contests.filter((c) => districts.includes(c.districtId)); + return election.contests.filter((c) => { + if (!districts.includes(c.districtId)) { + return false; + } + // In primaries, candidate contests carry a partyId and appear only on the + // matching party's ballot style. Yes/no contests (ballot measures) have no + // party and appear on every ballot style whose districts include them. + // General elections have no partyId on the ballot style, so this is a no-op. + if (ballotStyle.partyId && c.type === 'candidate' && c.partyId) { + return c.partyId === ballotStyle.partyId; + } + return true; + }); } export const RawBallotPdfInfo = z.strictObject({ @@ -377,6 +567,38 @@ export async function loadElectionPackage( }; } +/** + * Overwrite selected keys in an election package's systemSettings.json, in + * place, at `electionPackagePath`. Returns the merged settings. + * + * VxAdmin loads this package and re-exports it to VxScan, so the override + * reaches both apps. The dev/test election packages are unsigned, so rewriting + * a file inside the ZIP does not break any signature check. `undefined` values + * in `overrides` are ignored so partial overrides only touch the keys set. + */ +export async function applySystemSettingsOverrides( + electionPackagePath: string, + overrides: Record, +): Promise> { + const definedOverrides = Object.fromEntries( + Object.entries(overrides).filter(([, value]) => value !== undefined), + ); + + const zipData = await readFile(electionPackagePath); + const zip = await JSZip.loadAsync(zipData); + const systemSettingsFile = zip.file('systemSettings.json'); + assert(systemSettingsFile, `systemSettings.json missing in ${electionPackagePath}`); + + const current = JSON.parse(await systemSettingsFile.async('string')) as Record; + const merged = { ...current, ...definedOverrides }; + + zip.file('systemSettings.json', JSON.stringify(merged, null, 2)); + const updated = await zip.generateAsync({ type: 'nodebuffer' }); + await writeFile(electionPackagePath, updated); + + return merged; +} + /** * Download an election package from a URL and load it. */ diff --git a/src/cli/config-runner.ts b/src/cli/config-runner.ts index f552113..e09578d 100644 --- a/src/cli/config-runner.ts +++ b/src/cli/config-runner.ts @@ -5,6 +5,7 @@ import { logger, formatDuration, printDivider } from '../utils/logger.js'; import { resolvePath } from '../utils/paths.js'; import type { QARunConfig, WebhookConfig } from '../config/types.js'; +import { getVersionSpec } from '../config/versions.js'; import { existsSync } from 'node:fs'; import { relative } from 'node:path'; @@ -18,10 +19,11 @@ import { } from '../repo/bootstrap.js'; // Election package loading -import { loadElectionPackage } from '../ballots/election-loader.js'; +import { applySystemSettingsOverrides, loadElectionPackage } from '../ballots/election-loader.js'; // App orchestration import { createAppOrchestrator, ensureNoAppsRunning } from '../apps/orchestrator.js'; +import { MOCK_NODE_ENV } from '../apps/env-config.js'; // Browser automation import { createBrowserSession } from '../automation/browser.js'; @@ -65,7 +67,7 @@ function buildResultsUrl(reportPath: string): string | undefined { } /** - * Get the project root directory (where vxsuite.patch is located) + * Get the project root directory (where the vxsuite-*.patch files are located) */ function getProjectRoot(): string { // Get the directory of this source file @@ -135,12 +137,15 @@ export async function runQAWorkflow(config: QARunConfig, options: RunOptions = { const commit = await getCurrentCommit(repoPath); logger.info(`Repository at ${repoPath} (commit: ${commit.slice(0, 8)})`); - // Apply patch if it exists (look in project root, not CWD) + // Apply the version-specific patch if it exists (look in project root, not CWD) const projectRoot = getProjectRoot(); - const patchPath = join(projectRoot, 'vxsuite.patch'); + const { patchFile } = getVersionSpec(config.vxsuite.version); + const patchPath = join(projectRoot, patchFile); if (existsSync(patchPath)) { logger.info(`Applying patch from ${patchPath}`); await applyPatch(repoPath, patchPath); + } else { + throw new Error(`Patch file not found for version ${config.vxsuite.version}: ${patchPath}`); } await bootstrapRepo(repoPath); @@ -167,17 +172,48 @@ export async function runQAWorkflow(config: QARunConfig, options: RunOptions = { electionSourcePath, collector.getBallotsDir(), ); - assert( - electionPackage.systemSettings['disallowCastingOvervotes'], - `System setting 'disallowCastingOvervotes' must be true`, - ); + + // Apply any systemSettings overrides to the package VxAdmin will load (and + // re-export to VxScan), so a single package can exercise different behaviors. + const systemSettingsOverrides = config.election.systemSettingsOverrides; + if (systemSettingsOverrides && Object.keys(systemSettingsOverrides).length > 0) { + logger.info(`Applying systemSettings overrides: ${JSON.stringify(systemSettingsOverrides)}`); + electionPackage.systemSettings = await applySystemSettingsOverrides( + electionPackagePath, + systemSettingsOverrides as Record, + ); + } const { election } = electionPackage.electionDefinition; + // Which ballots VxScan returns to the voter for review (vs. counts) depends + // on the election's precinct-scan adjudication reasons. Notably, when + // 'BlankBallot' is configured (e.g. the primary fixture), blank ballots — + // and effectively-blank ballots such as our unmarked-write-in pattern, + // which fills no bubbles — are returned rather than accepted. + const precinctScanAdjudicationReasons = Array.isArray( + electionPackage.systemSettings['precinctScanAdjudicationReasons'], + ) + ? (electionPackage.systemSettings['precinctScanAdjudicationReasons'] as string[]) + : []; + const blankBallotRequiresReview = precinctScanAdjudicationReasons.includes('BlankBallot'); + + // When overvotes may not be cast, an overvoted ballot can only be returned; + // when they may be cast, the voter can choose to cast it anyway. This drives + // how many overvote ballots we scan and what outcome we expect for each. + const disallowCastingOvervotes = + electionPackage.systemSettings['disallowCastingOvervotes'] === true; + logger.info(`Election: ${election.title}`); logger.info(`Ballot styles: ${election.ballotStyles.length}`); logger.info(`Contests: ${election.contests.length}`); logger.info(`Ballot PDFs loaded: ${electionPackage.ballots.length}`); + logger.info( + `Precinct-scan adjudication reasons: ${ + precinctScanAdjudicationReasons.join(', ') || '(none)' + }`, + ); + logger.info(`Casting overvotes: ${disallowCastingOvervotes ? 'disallowed' : 'allowed'}`); // Phase 4: Prepare ballots for scanning printDivider(); @@ -227,30 +263,43 @@ export async function runQAWorkflow(config: QARunConfig, options: RunOptions = { ballotType: ballot.ballotType, pattern: 'blank', pdfPath, - expectedAccepted: ballot.ballotMode === 'official', + // A blank ballot is counted only if the scanner is in official mode + // (test ballots are returned) and blank ballots aren't flagged for + // review by the election's adjudication settings. + expectedAccepted: ballot.ballotMode === 'official' && !blankBallotRequiresReview, }); if (ballot.ballotMode === 'official') { - ballotsToScan.push( - { - ballotStyleId: ballot.ballotStyleId, - precinctId: ballot.precinctId, - ballotMode: ballot.ballotMode, - ballotType: ballot.ballotType, - pattern: 'valid', - pdfPath, - expectedAccepted: true, - }, - { - ballotStyleId: ballot.ballotStyleId, - precinctId: ballot.precinctId, - ballotMode: ballot.ballotMode, - ballotType: ballot.ballotType, - pattern: 'overvote', - pdfPath, - expectedAccepted: false, - }, - ); + ballotsToScan.push({ + ballotStyleId: ballot.ballotStyleId, + precinctId: ballot.precinctId, + ballotMode: ballot.ballotMode, + ballotType: ballot.ballotType, + pattern: 'valid', + pdfPath, + expectedAccepted: true, + }); + + const overvoteBase = { + ballotStyleId: ballot.ballotStyleId, + precinctId: ballot.precinctId, + ballotMode: ballot.ballotMode, + ballotType: ballot.ballotType, + pattern: 'overvote' as const, + pdfPath, + }; + if (disallowCastingOvervotes) { + // Overvotes cannot be cast: verify the ballot is returned, not counted. + ballotsToScan.push({ ...overvoteBase, expectedAccepted: false }); + } else { + // Overvotes may be cast: exercise both voter choices — cast one + // (counted) and return one (rejected) — so tallies and reports + // reflect a cast overvote. + ballotsToScan.push( + { ...overvoteBase, expectedAccepted: true }, + { ...overvoteBase, expectedAccepted: false }, + ); + } ballotsToScan.push( { @@ -269,7 +318,11 @@ export async function runQAWorkflow(config: QARunConfig, options: RunOptions = { ballotType: ballot.ballotType, pattern: 'unmarked-write-in', pdfPath, - expectedAccepted: true, + // The unmarked-write-in pattern fills no bubbles, so the ballot has + // no counted votes and is treated as blank: returned for review + // when blank ballots are flagged, otherwise counted (and the + // unmarked write-in flagged for later adjudication in VxAdmin). + expectedAccepted: !blankBallotRequiresReview, }, ); } @@ -320,7 +373,15 @@ export async function runQAWorkflow(config: QARunConfig, options: RunOptions = { // FIXME: It'd be nice to not need to hardcode this as the mock USB drive data location. // Perhaps the dev dock API could offer a way to add files, or we'd use a mocking approach // that happens more at the Linux system level. - const dataPath = join(config.vxsuite.repoPath, 'libs/usb-drive/dev-workspace/mock-usb-data'); + // VxSuite stores mock USB data under /.mock-state// (see + // getMockStateRootDir + file_usb_drive.ts). The subdirectory is version-specific: + // v4.1 nests the drive under a disk name (usb-drive/sdb/...), v4.0 does not. + const dataPath = join( + repoPath, + '.mock-state', + MOCK_NODE_ENV, + getVersionSpec(config.vxsuite.version).mockUsbDataDir, + ); try { await runAdminConfigureWorkflow( diff --git a/src/cli/serve.ts b/src/cli/serve.ts index 6ea0b8c..94b5dec 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -15,6 +15,7 @@ import { resolvePath, generateTimestampedDir, ensureDir } from '../utils/paths.j import { runQAWorkflow } from './config-runner.js'; import { downloadFile } from '../ballots/election-loader.js'; import type { QARunConfig } from '../config/types.js'; +import { SUPPORTED_VERSIONS, type VxSuiteVersion } from '../config/versions.js'; export interface ServeOptions { port: number; @@ -54,6 +55,7 @@ export function startServe(options: ServeOptions): void { webhook_url?: string; qa_run_id?: string; election_id?: string; + vxsuite_version?: string; }; }; try { @@ -67,6 +69,7 @@ export function startServe(options: ServeOptions): void { const params = data.parameters ?? {}; const exportPackageUrl = params.export_package_url; const webhookUrl = params.webhook_url; + const vxsuiteVersion = params.vxsuite_version; if (!exportPackageUrl) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -78,6 +81,16 @@ export function startServe(options: ServeOptions): void { return; } + if (vxsuiteVersion && !(SUPPORTED_VERSIONS as readonly string[]).includes(vxsuiteVersion)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: `Invalid vxsuite_version "${vxsuiteVersion}". Supported: ${SUPPORTED_VERSIONS.join(', ')}`, + }), + ); + return; + } + pipelineCounter += 1; const pipelineId = `local-pipeline-${Date.now()}-${pipelineCounter}`; @@ -86,6 +99,7 @@ export function startServe(options: ServeOptions): void { logger.info(` webhook_url: ${webhookUrl ?? '(none)'}`); logger.info(` qa_run_id: ${params.qa_run_id ?? '(none)'}`); logger.info(` election_id: ${params.election_id ?? '(none)'}`); + logger.info(` vxsuite_version: ${vxsuiteVersion ?? '(config default)'}`); // Respond immediately with a mock pipeline response res.writeHead(201, { 'Content-Type': 'application/json' }); @@ -100,7 +114,12 @@ export function startServe(options: ServeOptions): void { // Run the QA workflow in the background running = true; - void runPipeline(options, exportPackageUrl, webhookUrl).finally(() => { + void runPipeline( + options, + exportPackageUrl, + webhookUrl, + vxsuiteVersion as VxSuiteVersion | undefined, + ).finally(() => { running = false; }); }); @@ -137,6 +156,7 @@ async function runPipeline( options: ServeOptions, exportPackageUrl: string, webhookUrl: string | undefined, + vxsuiteVersion: VxSuiteVersion | undefined, ): Promise { try { // Load the config @@ -146,6 +166,11 @@ async function runPipeline( const config: QARunConfig = validateConfig(parsedConfig, configPath); config.basePath = dirname(configPath); + // Override VxSuite version from the request payload, if provided + if (vxsuiteVersion) { + config.vxsuite.version = vxsuiteVersion; + } + // Override election source with the URL from the request config.election.source = exportPackageUrl; diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index e74313c..9cc06f3 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -10,7 +10,7 @@ describe('validateConfig', () => { const config = { vxsuite: { repoPath: './vxsuite', - ref: 'v4.0.4', + version: 'v4.0', }, election: { source: './election.json', @@ -30,7 +30,7 @@ describe('validateConfig', () => { const config = { vxsuite: { repoPath: '~/.vx-qa/vxsuite', - ref: 'v4.0.4', + version: 'v4.0', }, election: { source: './election.json', @@ -50,7 +50,7 @@ describe('validateConfig', () => { const config = { vxsuite: { repoPath: '~/.vx-qa/vxsuite', - ref: 'v4.0.4', + version: 'v4.0', }, election: { source: './election.json', @@ -70,7 +70,7 @@ describe('validateConfig', () => { const config = { vxsuite: { repoPath: '/absolute/path/vxsuite', - ref: 'v4.0.4', + version: 'v4.0', }, election: { source: '/absolute/path/election.json', @@ -92,7 +92,7 @@ describe('validateConfig', () => { const config = { vxsuite: { repoPath: '../shared/vxsuite', - ref: 'v4.0.4', + version: 'v4.0', }, election: { source: '../elections/election.json', @@ -114,7 +114,7 @@ describe('validateConfig', () => { const config = { vxsuite: { repoPath: './vxsuite', - ref: 'v4.0.4', + version: 'v4.0', }, election: { source: './election.json', @@ -134,7 +134,7 @@ describe('validateConfig', () => { const config = { vxsuite: { repoPath: '', - ref: 'v4.0.4', + version: 'v4.0', }, election: { source: './election.json', @@ -157,7 +157,7 @@ describe('safeValidateConfig', () => { const config = { vxsuite: { repoPath: './vxsuite', - ref: 'v4.0.4', + version: 'v4.0', }, election: { source: './election.json', @@ -178,7 +178,7 @@ describe('safeValidateConfig', () => { const config = { vxsuite: { repoPath: '', - ref: 'v4.0.4', + version: 'v4.0', }, election: { source: './election.json', diff --git a/src/config/schema.ts b/src/config/schema.ts index 4c64e02..ebb3094 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -5,6 +5,7 @@ import { z } from 'zod/v4'; import { resolvePath } from '../utils/paths.js'; import { dirname } from 'node:path'; +import { SUPPORTED_VERSIONS } from './versions.js'; export const BallotPatternSchema = z.enum([ 'blank', @@ -16,12 +17,25 @@ export const BallotPatternSchema = z.enum([ export const VxSuiteConfigSchema = z.object({ repoPath: z.string().min(1, 'Repository path is required'), - ref: z.string().min(1, 'Tag/branch/rev is required'), + version: z.enum(SUPPORTED_VERSIONS, { + message: `VxSuite version must be one of: ${SUPPORTED_VERSIONS.join(', ')}`, + }), forceClone: z.boolean().optional().default(false), }); +/** + * Overrides applied to the election package's systemSettings.json before it is + * loaded into VxAdmin (and re-exported to VxScan). Lets a single election + * package exercise different behaviors — e.g. flipping `disallowCastingOvervotes` + * to test the cast-overvote path — without maintaining a separate package. + */ +export const SystemSettingsOverridesSchema = z.object({ + disallowCastingOvervotes: z.boolean().optional(), +}); + export const ElectionConfigSchema = z.object({ source: z.string().min(1, 'Election source path is required'), + systemSettingsOverrides: SystemSettingsOverridesSchema.optional(), }); export const OutputConfigSchema = z.object({ diff --git a/src/config/types.ts b/src/config/types.ts index 174f2ed..58a81ad 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -4,6 +4,7 @@ import { BallotToScan } from '../automation/scan-workflow.js'; import { BallotMode, BallotType, VotesDict } from '../ballots/election-loader.js'; +import type { VxSuiteVersion } from './versions.js'; export type BallotPattern = | 'blank' @@ -15,15 +16,25 @@ export type BallotPattern = export interface VxSuiteConfig { /** Path where VxSuite repo should be cloned */ repoPath: string; - /** Git tag/branch/rev to checkout (e.g., "v4.0.4") */ - ref: string; + /** VxSuite version to run against; the git ref is derived from this. */ + version: VxSuiteVersion; /** Force a fresh clone even if repo exists */ forceClone?: boolean; } +export interface SystemSettingsOverrides { + /** Override whether VxScan refuses to cast overvoted ballots. */ + disallowCastingOvervotes?: boolean; +} + export interface ElectionConfig { /** Path to election package ZIP (election-package-and-ballots-*.zip) */ source: string; + /** + * Optional overrides applied to the election package's systemSettings before + * loading it into VxAdmin, so one package can exercise multiple behaviors. + */ + systemSettingsOverrides?: SystemSettingsOverrides; } export interface BallotConfig { diff --git a/src/config/versions.ts b/src/config/versions.ts new file mode 100644 index 0000000..2fb52a0 --- /dev/null +++ b/src/config/versions.ts @@ -0,0 +1,67 @@ +/** + * Supported VxSuite versions and how the QA tool maps each one to a concrete + * git ref, patch file, and ballot data model. + * + * A QA job specifies only a `version`; everything version-specific is derived + * from it here. Older versions (<= v4.0.4) are intentionally unsupported. + */ + +/** + * Versions of VxSuite the QA tool knows how to run against. These match + * VxSuite's own `SoftwareVersion` values (`v4.0`, `v4.1`); the specific git tag + * for each is resolved via {@link VERSION_SPECS}. + */ +export const SUPPORTED_VERSIONS = ['v4.0', 'v4.1'] as const; + +export type VxSuiteVersion = (typeof SUPPORTED_VERSIONS)[number]; + +/** + * The election ballot-geometry data model used by a given version. + * + * - `gridLayouts`: election definition carries top-level `gridLayouts` + * (v4.0.x). + * - `ballotPositions`: geometry moved onto `ballotStyles[].ballotPositions` + * (v4.1+). The election loader normalizes this back into the flat + * grid-position model the rest of the tool expects. + */ +export type BallotModel = 'gridLayouts' | 'ballotPositions'; + +export interface VersionSpec { + /** Git tag/branch to check out in the VxSuite repo. */ + ref: string; + /** Patch file (relative to project root) to apply after checkout. */ + patchFile: string; + /** Ballot-geometry data model used by this version's election definition. */ + ballotModel: BallotModel; + /** + * Mock USB drive data directory, relative to `/.mock-state/`. + * v4.1 nests the drive under a disk name (`sdb`); v4.0 does not. + */ + mockUsbDataDir: string; +} + +export const VERSION_SPECS: Record = { + 'v4.0': { + ref: 'v4.0.7', + patchFile: 'vxsuite-v4.0.patch', + ballotModel: 'gridLayouts', + mockUsbDataDir: 'usb-drive/mock-usb-data', + }, + 'v4.1': { + // v4.1 is not yet tagged for release; the alpha tag is the pinned point. + ref: 'v4.1.0-alpha', + patchFile: 'vxsuite-v4.1.patch', + ballotModel: 'ballotPositions', + mockUsbDataDir: 'usb-drive/sdb/mock-usb-data', + }, +}; + +/** Look up the version spec for a supported version. */ +export function getVersionSpec(version: VxSuiteVersion): VersionSpec { + return VERSION_SPECS[version]; +} + +/** Resolve the git ref to check out for a given version. */ +export function refForVersion(version: VxSuiteVersion): string { + return VERSION_SPECS[version].ref; +} diff --git a/src/index.ts b/src/index.ts index be6f8d7..f52e6b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,12 +6,13 @@ * CLI entry point for automating QA testing of VxSuite elections */ -import { Command } from 'commander'; +import { Command, InvalidArgumentError } from 'commander'; import { logger, printHeader } from './utils/logger.js'; import { validateConfig, safeValidateConfig } from './config/schema.js'; import { resolvePath, generateTimestampedDir, ensureDir } from './utils/paths.js'; import { runQAWorkflow } from './cli/config-runner.js'; import type { QARunConfig, WebhookConfig } from './config/types.js'; +import { SUPPORTED_VERSIONS } from './config/versions.js'; import { dirname, join } from 'node:path'; import { regenerateHtmlReportFromRawData } from './report/html-generator.js'; import { revalidateTallyResults } from './automation/admin-tally-workflow.js'; @@ -19,6 +20,19 @@ import { downloadFile } from './ballots/election-loader.js'; import { startServe } from './cli/serve.js'; import { readFile, writeFile } from 'node:fs/promises'; +/** + * Commander option coercion for integer arguments. Passing `parseInt` directly + * is a bug: Commander calls it as `parseInt(value, previous)`, so the previous + * option value is used as the radix (e.g. `parseInt('9100', 9000)` -> NaN). + */ +function parseIntOption(value: string): number { + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed)) { + throw new InvalidArgumentError('Must be an integer.'); + } + return parsed; +} + const program = new Command(); program @@ -31,15 +45,19 @@ program .description('Run QA automation workflow') .option('-c, --config ', 'Path to configuration file') .option('-o, --output ', 'Override output directory') - .option('-r, --ref ', 'Override VxSuite tag/branch/ref') + .option('--vxsuite-version ', 'Override VxSuite version (e.g. v4.0, v4.1)') .option('-e, --election ', 'Override election source path') .option('--headless', 'Run browser in headless mode (default)') .option('--no-headless', 'Run browser in headed mode for debugging') - .option('--limit-ballots ', 'Limit the number of ballots to scan (for testing)', parseInt) + .option( + '--limit-ballots ', + 'Limit the number of ballots to scan (for testing)', + parseIntOption, + ) .option( '--limit-manual-tallies ', 'Limit the number of ballot styles with manual tallies (for testing)', - parseInt, + parseIntOption, ) .option('--webhook-url ', 'URL for status callbacks') .option( @@ -70,8 +88,14 @@ program if (options.output) { config.output.directory = options.output; } - if (options.tag) { - config.vxsuite.ref = options.tag; + if (options.vxsuiteVersion) { + if (!(SUPPORTED_VERSIONS as readonly string[]).includes(options.vxsuiteVersion)) { + logger.error( + `Invalid --vxsuite-version "${options.vxsuiteVersion}". Supported: ${SUPPORTED_VERSIONS.join(', ')}`, + ); + process.exit(1); + } + config.vxsuite.version = options.vxsuiteVersion; } if (options.election) { config.election.source = options.election; @@ -183,7 +207,7 @@ program const sampleConfig: QARunConfig = { vxsuite: { repoPath: '~/.vx-qa/vxsuite', - ref: 'v4.0.4', + version: 'v4.0', }, election: { source: './election-package-and-ballots.zip', @@ -203,15 +227,19 @@ program .command('serve') .description('Start a local CircleCI stand-in server for VxDesign') .requiredOption('-c, --config ', 'Path to configuration file') - .option('-p, --port ', 'Port to listen on', parseInt, 9000) + .option('-p, --port ', 'Port to listen on', parseIntOption, 9000) .option('--webhook-secret ', 'Secret for webhook callbacks', 'test-secret') .option('--headless', 'Run browser in headless mode (default)') .option('--no-headless', 'Run browser in headed mode for debugging') - .option('--limit-ballots ', 'Limit the number of ballots to scan (for testing)', parseInt) + .option( + '--limit-ballots ', + 'Limit the number of ballots to scan (for testing)', + parseIntOption, + ) .option( '--limit-manual-tallies ', 'Limit the number of ballot styles with manual tallies (for testing)', - parseInt, + parseIntOption, ) .action((options) => { startServe({ diff --git a/src/mock-hardware/client.ts b/src/mock-hardware/client.ts index da309ac..8ff5e0f 100644 --- a/src/mock-hardware/client.ts +++ b/src/mock-hardware/client.ts @@ -1,8 +1,9 @@ /** * HTTP client for communicating with the dev-dock API * - * The dev-dock API runs at e.g. http://localhost:3004/dock when an app is started - * with mock hardware enabled. + * The dev-dock API runs at e.g. http://localhost:3001/dock (the app backend) + * when an app is started with mock hardware enabled. It is also reachable + * through the Vite frontend proxy at http://localhost:3000/dock. */ import { logger } from '../utils/logger.js'; diff --git a/src/mock-hardware/scanner.ts b/src/mock-hardware/scanner.ts index c8a6984..91af3f1 100644 --- a/src/mock-hardware/scanner.ts +++ b/src/mock-hardware/scanner.ts @@ -5,11 +5,25 @@ import { createDevDockClient, type DevDockClient } from './client.js'; import { logger } from '../utils/logger.js'; -export type SheetStatus = 'noSheet' | 'sheetInserted' | 'sheetHeldInFront' | 'sheetHeldInBack'; +/** + * Mock sheet status reported by VxSuite's mock PDI scanner + * (libs/pdi-scanner/src/ts/mock_scanner.ts). + */ +export type SheetStatus = + | 'noSheetEnabled' + | 'noSheetDisabled' + | 'sheetInserted' + | 'sheetHeldInFront'; + +interface PdiScannerStatus { + sheetStatus: SheetStatus; + queue?: { total: number; inserted: number }; +} export interface MockScannerController { /** - * Insert a ballot sheet (PDF) into the scanner + * Insert a ballot sheet (PDF) into the scanner. The PDF may contain one sheet + * (one or two pages); the mock feeds its sheets into the scanner via a queue. */ insertSheet(pdfPath: string): Promise; @@ -41,7 +55,10 @@ export function createMockScannerController(): MockScannerController { return { async insertSheet(pdfPath: string): Promise { logger.debug(`Inserting sheet: ${pdfPath}`); - await client.call('pdiScannerInsertSheet', { path: pdfPath }); + // Clear any leftover queue from a previous insert so the new one is + // accepted (insertSheets refuses to run while a queue is active). + await client.call('pdiScannerClearSheetQueue', {}); + await client.call('pdiScannerInsertSheets', { path: pdfPath }); }, async removeSheet(): Promise { @@ -50,7 +67,8 @@ export function createMockScannerController(): MockScannerController { }, async getSheetStatus(): Promise { - return await client.call('pdiScannerGetSheetStatus', {}); + const status = await client.call('pdiScannerGetStatus', {}); + return status.sheetStatus; }, async waitForStatus( diff --git a/src/mock-hardware/usb.ts b/src/mock-hardware/usb.ts index 236b12a..dd8ca8a 100644 --- a/src/mock-hardware/usb.ts +++ b/src/mock-hardware/usb.ts @@ -2,8 +2,8 @@ * Mock USB drive control via dev-dock API */ -import { basename, join } from 'node:path'; -import { cp, writeFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; +import { cp, mkdir, writeFile } from 'node:fs/promises'; import { createDevDockClient, type DevDockClient } from './client.js'; import { logger } from '../utils/logger.js'; @@ -84,6 +84,7 @@ export function createMockUsbController({ dataPath }: { dataPath: string }): Moc async copyFile(sourcePath: string, destName?: string): Promise { const fileName = destName || basename(sourcePath); const destPath = join(dataPath, fileName); + await mkdir(dirname(destPath), { recursive: true }); await cp(sourcePath, destPath); logger.debug(`Copied ${sourcePath} to USB as ${fileName}`); return destPath; @@ -92,6 +93,7 @@ export function createMockUsbController({ dataPath }: { dataPath: string }): Moc async copyDirectory(sourcePath: string, destName?: string): Promise { const dirName = destName || basename(sourcePath); const destPath = join(dataPath, dirName); + await mkdir(dirname(destPath), { recursive: true }); await cp(sourcePath, destPath, { recursive: true }); logger.debug(`Copied directory ${sourcePath} to USB as ${dirName}`); return destPath; @@ -99,6 +101,7 @@ export function createMockUsbController({ dataPath }: { dataPath: string }): Moc async writeFile(fileName: string, content: Buffer | string): Promise { const filePath = join(dataPath, fileName); + await mkdir(dirname(filePath), { recursive: true }); await writeFile(filePath, content); logger.debug(`Wrote ${fileName} to USB`); return filePath; diff --git a/src/repo/bootstrap.ts b/src/repo/bootstrap.ts index 0778f95..be5f5da 100644 --- a/src/repo/bootstrap.ts +++ b/src/repo/bootstrap.ts @@ -3,6 +3,7 @@ */ import { existsSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { logger } from '../utils/logger.js'; import { execCommandWithOutput, execCommand } from '../utils/process.js'; @@ -44,6 +45,12 @@ export async function bootstrapRepo(repoPath: string): Promise { logger.step('Bootstrapping admin and scan apps (this may take several minutes)...'); + // Use the pnpm version VxSuite pins in its `packageManager` field. Different + // VxSuite versions pin different pnpm versions (e.g. v4.0 -> 8.15.5, v4.1 -> + // 9.15.9), and building under the wrong pnpm can mis-resolve optional native + // deps (e.g. v4.1's Vite/rolldown binding fails to install under pnpm 10). + await useVxSuitePinnedPnpm(repoPath); + // First, run pnpm install at the root to set up all workspace symlinks logger.info('Installing workspace dependencies...'); const installCode = await execCommandWithOutput('pnpm', ['install'], { @@ -55,6 +62,14 @@ export async function bootstrapRepo(repoPath: string): Promise { throw new Error(`pnpm install failed with code ${installCode}`); } + // Allow the Rust addon builds to fetch crates. VxSuite's addons (pdi-scanner, + // ballot-interpreter) build with `cargo build --offline`, which requires + // every crate to already be in that build's cargo cache. The CI image only + // caches crates for its own VxSuite version, so building a different ref hits + // crates it hasn't seen (e.g. `csv`) and fails. Drop `--offline` so the build + // downloads what it needs. + await allowOnlineRustBuilds(repoPath); + // Then build just the admin and scan apps (and their dependencies) // Use the "..." filter syntax to include all dependencies // Build each app separately in sequence to ensure dependencies are built first @@ -74,6 +89,68 @@ export async function bootstrapRepo(repoPath: string): Promise { logger.success('Admin and scan apps bootstrapped successfully'); } +/** + * Install (globally) the pnpm version VxSuite pins in its `packageManager` + * field, so the workspace is built with the pnpm it was locked and tested with. + * No-op if the field is missing/unparseable. + */ +async function useVxSuitePinnedPnpm(repoPath: string): Promise { + let packageManager: string | undefined; + try { + const pkg = JSON.parse(await readFile(join(repoPath, 'package.json'), 'utf-8')); + packageManager = pkg.packageManager; + } catch { + return; + } + + // e.g. "pnpm@9.15.9" or "pnpm@9.15.9+sha512.abc..." + const match = packageManager?.match(/^pnpm@(\d+\.\d+\.\d+)/); + if (!match) { + return; + } + + const version = match[1]; + logger.info(`Installing VxSuite's pinned pnpm@${version}...`); + const code = await execCommandWithOutput('npm', ['install', '-g', `pnpm@${version}`], { + env: { ...process.env }, + }); + if (code !== 0) { + logger.warn(`Failed to install pnpm@${version} (code ${code}); continuing with current pnpm`); + } +} + +/** + * Rust addon build scripts that hard-code `cargo build --offline`, relative to + * the VxSuite repo root. Offline builds require all crates to be pre-cached, + * which isn't guaranteed when building an arbitrary ref in CI. + */ +const RUST_ADDON_PACKAGE_JSONS = [ + 'libs/pdi-scanner/package.json', + 'libs/ballot-interpreter/package.json', +]; + +/** + * Strip `--offline` from VxSuite's Rust addon build scripts so cargo can + * download any crates missing from the local cache. Idempotent; skips files + * that don't exist or don't use `--offline`. + */ +async function allowOnlineRustBuilds(repoPath: string): Promise { + for (const relPath of RUST_ADDON_PACKAGE_JSONS) { + const filePath = join(repoPath, relPath); + if (!existsSync(filePath)) { + continue; + } + + const contents = await readFile(filePath, 'utf-8'); + if (!contents.includes(' --offline')) { + continue; + } + + await writeFile(filePath, contents.replaceAll(' --offline', '')); + logger.info(`Removed --offline from ${relPath}`); + } +} + /** * Installs the playwright browsers needed by vxsuite. Note that the versions * of these browser may be different than the versions installed by our version diff --git a/src/repo/clone.ts b/src/repo/clone.ts index 3be98be..8606aab 100644 --- a/src/repo/clone.ts +++ b/src/repo/clone.ts @@ -7,6 +7,7 @@ import { existsSync } from 'node:fs'; import { logger } from '../utils/logger.js'; import { ensureDir, resolvePath } from '../utils/paths.js'; import type { VxSuiteConfig } from '../config/types.js'; +import { refForVersion } from '../config/versions.js'; import { rm, readFile } from 'node:fs/promises'; import { spawn } from 'node:child_process'; @@ -29,7 +30,7 @@ export async function cloneOrUpdateRepo(config: VxSuiteConfig): Promise await updateRepo(repoPath); } - await checkoutTag(repoPath, config.ref); + await checkoutTag(repoPath, refForVersion(config.version)); return repoPath; } diff --git a/src/repo/state.ts b/src/repo/state.ts index ea22f4f..50f683f 100644 --- a/src/repo/state.ts +++ b/src/repo/state.ts @@ -3,25 +3,21 @@ */ import { rm, readdir, cp, mkdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { logger } from '../utils/logger.js'; -import { expandHome, resolvePath } from '../utils/paths.js'; +import { resolvePath } from '../utils/paths.js'; export class State { static defaultFor(repoPath: string): State { - return new State( - repoPath, - join(repoPath, 'libs/usb-drive/dev-workspace'), - join(repoPath, 'libs/fujitsu-thermal-printer/dev-workspace'), - expandHome('~/.vx-dev-dock'), - ); + // VxSuite keeps all device mock state (USB drive, printers, smart card) under + // /.mock-state// (see getMockStateRootDir in libs/utils). + return new State(repoPath, join(repoPath, '.mock-state')); } private constructor( private readonly repoPath: string, - private readonly usbDrivePath: string, - private readonly printerPath: string, - private readonly devDockPath: string, + private readonly mockStatePath: string, ) {} async clear(): Promise { @@ -35,9 +31,7 @@ export class State { const spinner = logger.spinner('Clearing mock state...'); try { - await clearDirectory(this.usbDrivePath); - await clearDirectory(this.printerPath); - await clearDirectory(this.devDockPath); + await clearDirectory(this.mockStatePath); await this.clearAppWorkspaces(); spinner.succeed('Mock state cleared'); @@ -68,9 +62,11 @@ export class State { [ ['admin', join(this.repoPath, 'apps/admin/backend/dev-workspace')], ['scan', join(this.repoPath, 'apps/scan/backend/dev-workspace')], - ['usb-drive', this.usbDrivePath], - ['fujitsu-thermal-printer', this.printerPath], + ['mock-state', this.mockStatePath], ].map(async ([name, path]) => { + if (!existsSync(path)) { + return; + } const workspacePath = join(outputPath, name); await mkdir(workspacePath, { recursive: true }); await cp(path, workspacePath, { recursive: true }); diff --git a/src/report/html-generator.ts b/src/report/html-generator.ts index 47a9d59..4c65d67 100644 --- a/src/report/html-generator.ts +++ b/src/report/html-generator.ts @@ -6,6 +6,7 @@ import Handlebars from 'handlebars'; import { dirname, join, relative } from 'node:path'; import { logger } from '../utils/logger.js'; import type { ArtifactCollection, ScreenshotArtifact } from '../config/types.js'; +import { refForVersion } from '../config/versions.js'; import { collectFilesInDir, loadCollection, PROOF_PREFIX } from './artifacts.js'; import { generatePdfThumbnail } from './pdf-thumbnail.js'; import { writeFile } from 'node:fs/promises'; @@ -247,7 +248,7 @@ async function prepareReportData( duration: duration ? formatDuration(duration) : 'N/A', pass, config: { - tag: collection.config.vxsuite.ref, + tag: `${collection.config.vxsuite.version} (${refForVersion(collection.config.vxsuite.version)})`, election: collection.config.election.source, }, steps, @@ -489,7 +490,7 @@ function renderTemplate(data: ReportData): string {
{{duration}}
-
VxSuite Tag
+
VxSuite Version
{{config.tag}}
diff --git a/src/utils/process.ts b/src/utils/process.ts index 89a6f06..6b8fa28 100644 --- a/src/utils/process.ts +++ b/src/utils/process.ts @@ -81,7 +81,10 @@ export function spawnBackground( ): ChildProcess { const proc = spawn(command, args, { ...options, - detached: false, + // Start in a new process group so we can kill the whole group (Vite, + // backend, esbuild, tsc watchers) reliably, even when `ps`/`pgrep`/`lsof` + // aren't available for tree-kill to walk the tree. + detached: true, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -89,22 +92,27 @@ export function spawnBackground( } /** - * Kill a process and all its children + * Kill a process and all its children. + * + * Sends SIGKILL to the process group (works because we spawn detached, making + * the child a group leader) and also runs tree-kill as a fallback for any + * processes that escaped the group. */ export function killProcessTree(pid: number): Promise { + // Kill the entire process group. The negative pid targets the group led by + // the detached child. + try { + process.kill(-pid, 'SIGKILL'); + } catch { + // Group may already be gone, or not a group leader; fall back to tree-kill. + } + return new Promise((resolve) => { - treeKill(pid, 'SIGTERM', (err) => { - if (err) { - // Try SIGKILL if SIGTERM fails - treeKill(pid, 'SIGKILL', (killErr) => { - if (killErr) { - logger.warn(`Failed to kill process ${pid}: ${killErr.message}`); - } - resolve(); - }); - } else { - resolve(); + treeKill(pid, 'SIGKILL', (killErr) => { + if (killErr) { + logger.debug(`tree-kill fallback for ${pid}: ${killErr.message}`); } + resolve(); }); }); } diff --git a/test-fixtures/election-package-670d7ba-4e0b677.zip b/test-fixtures/election-package-670d7ba-4e0b677.zip new file mode 100644 index 0000000..ff5b776 Binary files /dev/null and b/test-fixtures/election-package-670d7ba-4e0b677.zip differ diff --git a/test-fixtures/election-package-and-ballots-v4.1.zip b/test-fixtures/election-package-and-ballots-v4.1.zip new file mode 100644 index 0000000..7abf693 Binary files /dev/null and b/test-fixtures/election-package-and-ballots-v4.1.zip differ diff --git a/test-fixtures/election-package-primary-v4.1.zip b/test-fixtures/election-package-primary-v4.1.zip new file mode 100644 index 0000000..6995c1d Binary files /dev/null and b/test-fixtures/election-package-primary-v4.1.zip differ diff --git a/test-fixtures/vx-qa-config-v4.0-overvote.json b/test-fixtures/vx-qa-config-v4.0-overvote.json new file mode 100644 index 0000000..8bd4a41 --- /dev/null +++ b/test-fixtures/vx-qa-config-v4.0-overvote.json @@ -0,0 +1,15 @@ +{ + "vxsuite": { + "repoPath": "~/.vx-qa/vxsuite", + "version": "v4.0" + }, + "election": { + "source": "./election-package-and-ballots-e71c80e-c4446e7.zip", + "systemSettingsOverrides": { + "disallowCastingOvervotes": false + } + }, + "output": { + "directory": "./output" + } +} diff --git a/test-fixtures/vx-qa-config-v4.0-primary.json b/test-fixtures/vx-qa-config-v4.0-primary.json new file mode 100644 index 0000000..c80db6d --- /dev/null +++ b/test-fixtures/vx-qa-config-v4.0-primary.json @@ -0,0 +1,12 @@ +{ + "vxsuite": { + "repoPath": "~/.vx-qa/vxsuite", + "version": "v4.0" + }, + "election": { + "source": "./election-package-670d7ba-4e0b677.zip" + }, + "output": { + "directory": "./output" + } +} diff --git a/test-fixtures/vx-qa-config-e71c80e.json b/test-fixtures/vx-qa-config-v4.0.json similarity index 89% rename from test-fixtures/vx-qa-config-e71c80e.json rename to test-fixtures/vx-qa-config-v4.0.json index 3c7e7e9..a90231a 100644 --- a/test-fixtures/vx-qa-config-e71c80e.json +++ b/test-fixtures/vx-qa-config-v4.0.json @@ -1,7 +1,7 @@ { "vxsuite": { "repoPath": "~/.vx-qa/vxsuite", - "ref": "v4.0.4" + "version": "v4.0" }, "election": { "source": "./election-package-and-ballots-e71c80e-c4446e7.zip" diff --git a/test-fixtures/vx-qa-config-v4.1-overvote.json b/test-fixtures/vx-qa-config-v4.1-overvote.json new file mode 100644 index 0000000..d7894b3 --- /dev/null +++ b/test-fixtures/vx-qa-config-v4.1-overvote.json @@ -0,0 +1,15 @@ +{ + "vxsuite": { + "repoPath": "~/.vx-qa/vxsuite", + "version": "v4.1" + }, + "election": { + "source": "./election-package-and-ballots-v4.1.zip", + "systemSettingsOverrides": { + "disallowCastingOvervotes": false + } + }, + "output": { + "directory": "./output" + } +} diff --git a/test-fixtures/vx-qa-config-v4.1-primary.json b/test-fixtures/vx-qa-config-v4.1-primary.json new file mode 100644 index 0000000..6449425 --- /dev/null +++ b/test-fixtures/vx-qa-config-v4.1-primary.json @@ -0,0 +1,12 @@ +{ + "vxsuite": { + "repoPath": "~/.vx-qa/vxsuite", + "version": "v4.1" + }, + "election": { + "source": "./election-package-primary-v4.1.zip" + }, + "output": { + "directory": "./output" + } +} diff --git a/test-fixtures/vx-qa-config-v4.1.json b/test-fixtures/vx-qa-config-v4.1.json new file mode 100644 index 0000000..e715ca0 --- /dev/null +++ b/test-fixtures/vx-qa-config-v4.1.json @@ -0,0 +1,12 @@ +{ + "vxsuite": { + "repoPath": "~/.vx-qa/vxsuite", + "version": "v4.1" + }, + "election": { + "source": "./election-package-and-ballots-v4.1.zip" + }, + "output": { + "directory": "./output" + } +} diff --git a/test-fixtures/vx-qa-config-vxdesign.json b/test-fixtures/vx-qa-config-vxdesign.json index a78ff80..7a5cd17 100644 --- a/test-fixtures/vx-qa-config-vxdesign.json +++ b/test-fixtures/vx-qa-config-vxdesign.json @@ -1,7 +1,7 @@ { "vxsuite": { "repoPath": "~/.vx-qa/vxsuite", - "ref": "v4.0.4" + "version": "v4.0" }, "election": { "source": "./placeholder.zip" diff --git a/vxsuite-v4.0.patch b/vxsuite-v4.0.patch new file mode 100644 index 0000000..15d9c5c --- /dev/null +++ b/vxsuite-v4.0.patch @@ -0,0 +1,139 @@ +diff --git a/libs/backend/src/system_call/get_audio_card_name.ts b/libs/backend/src/system_call/get_audio_card_name.ts +index 6cb7772cf..d4a36d399 100644 +--- a/libs/backend/src/system_call/get_audio_card_name.ts ++++ b/libs/backend/src/system_call/get_audio_card_name.ts +@@ -20,6 +20,11 @@ export interface GetAudioCardNameParams { + export async function getAudioCardName( + p: GetAudioCardNameParams + ): Promise> { ++ // vx-qa: no audio hardware in QA/dev environments; skip pactl detection. ++ if (p.nodeEnv !== 'production') { ++ return ok('mock.audio.card'); ++ } ++ + const maxAttempts = 1 + (p.maxRetries || 0); + const baseWaitTimeMs = 1000; + +diff --git a/libs/backend/src/system_call/pulse_audio.ts b/libs/backend/src/system_call/pulse_audio.ts +index b027b7160..c4c145a66 100644 +--- a/libs/backend/src/system_call/pulse_audio.ts ++++ b/libs/backend/src/system_call/pulse_audio.ts +@@ -22,6 +22,12 @@ export async function pactl( + logger: Logger, + args: string[] + ): Promise> { ++ // vx-qa: no audio hardware in QA/dev environments; treat pactl as a no-op so ++ // volume/profile calls succeed without a running pulseaudio. ++ if (nodeEnv !== 'production') { ++ return ok(''); ++ } ++ + try { + const res = + nodeEnv === 'production' +diff --git a/libs/backend/src/system_call/set_audio_volume.ts b/libs/backend/src/system_call/set_audio_volume.ts +index 09d9dceaf..de05e11a8 100644 +--- a/libs/backend/src/system_call/set_audio_volume.ts ++++ b/libs/backend/src/system_call/set_audio_volume.ts +@@ -31,6 +31,11 @@ export async function setAudioVolume(params: { + volumePct: number; + }): Promise { + const { logger, nodeEnv, sinkName, volumePct } = params; ++ ++ // vx-qa: no audio hardware in QA/dev environments; skip pactl. ++ if (nodeEnv !== 'production') { ++ return ok(); ++ } + let errorOutput: string; + + // NOTE: We allow apps to overdrive the system volume a bit to account for +diff --git a/libs/hmpb/src/marking.ts b/libs/hmpb/src/marking.ts +index 4bcd1f0e8..233163bc4 100644 +--- a/libs/hmpb/src/marking.ts ++++ b/libs/hmpb/src/marking.ts +@@ -60,6 +60,18 @@ const markSizeHalf = [markSize[0] * 0.5, markSize[1] * 0.5] as const; + const writeInFontSizeDefault = 12; + const writeInFontSizeReduced = 10; + ++export type DrawCallbackResult = 'draw' | 'ignore'; ++ ++/** ++ * Called for each mark before it is drawn. Returning 'ignore' skips drawing ++ * that mark. Used by QA tooling to produce e.g. unmarked write-ins (write-in ++ * name drawn without filling the bubble). ++ */ ++export type DrawCallback = ( ++ type: 'bubble' | 'write-in-text', ++ position: GridPosition ++) => DrawCallbackResult; ++ + /** + * Generates a PDF with bubble marks in the expected positions for the given + * ballot style and corresponding votes. +@@ -74,7 +86,8 @@ export async function generateMarkOverlay( + ballotStyleId: string, + votes: VotesDict, + calibration: PrintCalibration, +- baseBallotPdf?: Uint8Array ++ baseBallotPdf?: Uint8Array, ++ onDraw: DrawCallback = () => 'draw' + ): Promise { + assert( + election.gridLayouts, +@@ -153,10 +166,12 @@ export async function generateMarkOverlay( + ]; + + // Draw bubble mark using pdf-lib +- bubbleMark(page, [ +- bubbleCenter[0] - markSizeHalf[0], +- pageSize[1] - bubbleCenter[1] + markSizeHalf[1], +- ]); ++ if (onDraw('bubble', pos) === 'draw') { ++ bubbleMark(page, [ ++ bubbleCenter[0] - markSizeHalf[0], ++ pageSize[1] - bubbleCenter[1] + markSizeHalf[1], ++ ]); ++ } + + if (!mark.writeInName) continue; + +@@ -178,12 +193,14 @@ export async function generateMarkOverlay( + + origin[1] += 0.5 * (areaSize[1] - fontSize); + +- page.drawText(name, { +- font: fontRobotoBold, +- size: fontSize, +- x: origin[0], +- y: pageSize[1] - origin[1] - fontSize, +- }); ++ if (onDraw('write-in-text', pos) === 'draw') { ++ page.drawText(name, { ++ font: fontRobotoBold, ++ size: fontSize, ++ x: origin[0], ++ y: pageSize[1] - origin[1] - fontSize, ++ }); ++ } + } + + // Make sure we have an even number of pages, to match print order of the base +diff --git a/script/bootstrap b/script/bootstrap +index 8b3adce85..154e95c51 100755 +--- a/script/bootstrap ++++ b/script/bootstrap +@@ -2,11 +2,9 @@ + + set -euo pipefail + +-local_user=`logname` +-local_user_home_dir=$( getent passwd "${local_user}" | cut -d: -f6 ) +- +-# Make sure PATH includes cargo and /sbin +-export PATH="${local_user_home_dir}/.cargo/bin:${PATH}:/sbin/" ++# Make sure PATH includes cargo and /sbin. Use ${HOME} rather than `logname`, ++# which fails in CI containers with no controlling terminal. ++export PATH="${HOME}/.cargo/bin:${PATH}:/sbin/" + + DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + diff --git a/vxsuite-v4.1.patch b/vxsuite-v4.1.patch new file mode 100644 index 0000000..1867d74 --- /dev/null +++ b/vxsuite-v4.1.patch @@ -0,0 +1,139 @@ +diff --git a/libs/backend/src/system_call/get_audio_card_name.ts b/libs/backend/src/system_call/get_audio_card_name.ts +index 6cb7772cf..d4a36d399 100644 +--- a/libs/backend/src/system_call/get_audio_card_name.ts ++++ b/libs/backend/src/system_call/get_audio_card_name.ts +@@ -20,6 +20,11 @@ export interface GetAudioCardNameParams { + export async function getAudioCardName( + p: GetAudioCardNameParams + ): Promise> { ++ // vx-qa: no audio hardware in QA/dev environments; skip pactl detection. ++ if (p.nodeEnv !== 'production') { ++ return ok('mock.audio.card'); ++ } ++ + const maxAttempts = 1 + (p.maxRetries || 0); + const baseWaitTimeMs = 1000; + +diff --git a/libs/backend/src/system_call/pulse_audio.ts b/libs/backend/src/system_call/pulse_audio.ts +index b027b7160..c4c145a66 100644 +--- a/libs/backend/src/system_call/pulse_audio.ts ++++ b/libs/backend/src/system_call/pulse_audio.ts +@@ -22,6 +22,12 @@ export async function pactl( + logger: Logger, + args: string[] + ): Promise> { ++ // vx-qa: no audio hardware in QA/dev environments; treat pactl as a no-op so ++ // volume/profile calls succeed without a running pulseaudio. ++ if (nodeEnv !== 'production') { ++ return ok(''); ++ } ++ + try { + const res = + nodeEnv === 'production' +diff --git a/libs/backend/src/system_call/set_audio_volume.ts b/libs/backend/src/system_call/set_audio_volume.ts +index 09d9dceaf..de05e11a8 100644 +--- a/libs/backend/src/system_call/set_audio_volume.ts ++++ b/libs/backend/src/system_call/set_audio_volume.ts +@@ -31,6 +31,11 @@ export async function setAudioVolume(params: { + volumePct: number; + }): Promise { + const { logger, nodeEnv, sinkName, volumePct } = params; ++ ++ // vx-qa: no audio hardware in QA/dev environments; skip pactl. ++ if (nodeEnv !== 'production') { ++ return ok(); ++ } + let errorOutput: string; + + // NOTE: We allow apps to overdrive the system volume a bit to account for +diff --git a/libs/hmpb/src/marking.ts b/libs/hmpb/src/marking.ts +index ea7673ca5..d83879514 100644 +--- a/libs/hmpb/src/marking.ts ++++ b/libs/hmpb/src/marking.ts +@@ -61,6 +61,18 @@ const markSizeHalf = [markSize[0] * 0.5, markSize[1] * 0.5] as const; + const writeInFontSizeDefault = 12; + const writeInFontSizeReduced = 10; + ++export type DrawCallbackResult = 'draw' | 'ignore'; ++ ++/** ++ * Called for each mark before it is drawn. Returning 'ignore' skips drawing ++ * that mark. Used by QA tooling to produce e.g. unmarked write-ins (write-in ++ * name drawn without filling the bubble). ++ */ ++export type DrawCallback = ( ++ type: 'bubble' | 'write-in-text', ++ position: GridPosition ++) => DrawCallbackResult; ++ + /** + * Generates a PDF with bubble marks in the expected positions for the given + * ballot style and corresponding votes. +@@ -75,7 +87,8 @@ export async function generateMarkOverlay( + ballotStyleId: string, + votes: VotesDict, + calibration: PrintCalibration, +- baseBallotPdf?: Uint8Array ++ baseBallotPdf?: Uint8Array, ++ onDraw: DrawCallback = () => 'draw' + ): Promise { + const ballotStyle = election.ballotStyles.find( + (bs) => bs.id === ballotStyleId +@@ -155,10 +168,12 @@ export async function generateMarkOverlay( + ]; + + // Draw bubble mark using pdf-lib +- bubbleMark(page, [ +- bubbleCenter[0] - markSizeHalf[0], +- pageSize[1] - bubbleCenter[1] + markSizeHalf[1], +- ]); ++ if (onDraw('bubble', pos) === 'draw') { ++ bubbleMark(page, [ ++ bubbleCenter[0] - markSizeHalf[0], ++ pageSize[1] - bubbleCenter[1] + markSizeHalf[1], ++ ]); ++ } + + if (!mark.writeInName) continue; + +@@ -180,12 +195,14 @@ export async function generateMarkOverlay( + + origin[1] += 0.5 * (areaSize[1] - fontSize); + +- page.drawText(name, { +- font: fontRobotoBold, +- size: fontSize, +- x: origin[0], +- y: pageSize[1] - origin[1] - fontSize, +- }); ++ if (onDraw('write-in-text', pos) === 'draw') { ++ page.drawText(name, { ++ font: fontRobotoBold, ++ size: fontSize, ++ x: origin[0], ++ y: pageSize[1] - origin[1] - fontSize, ++ }); ++ } + } + + // Make sure we have an even number of pages, to match print order of the base +diff --git a/script/bootstrap b/script/bootstrap +index 8b3adce85..154e95c51 100755 +--- a/script/bootstrap ++++ b/script/bootstrap +@@ -2,11 +2,9 @@ + + set -euo pipefail + +-local_user=`logname` +-local_user_home_dir=$( getent passwd "${local_user}" | cut -d: -f6 ) +- +-# Make sure PATH includes cargo and /sbin +-export PATH="${local_user_home_dir}/.cargo/bin:${PATH}:/sbin/" ++# Make sure PATH includes cargo and /sbin. Use ${HOME} rather than `logname`, ++# which fails in CI containers with no controlling terminal. ++export PATH="${HOME}/.cargo/bin:${PATH}:/sbin/" + + DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + diff --git a/vxsuite.patch b/vxsuite.patch deleted file mode 100644 index b72c059..0000000 --- a/vxsuite.patch +++ /dev/null @@ -1,255 +0,0 @@ -diff --git a/apps/mark/backend/src/util/print_ballot.tsx b/apps/mark/backend/src/util/print_ballot.tsx -index 933f00d15..8d2367049 100644 ---- a/apps/mark/backend/src/util/print_ballot.tsx -+++ b/apps/mark/backend/src/util/print_ballot.tsx -@@ -112,7 +112,8 @@ async function printMarkOverlay(p: PrintBallotProps): Promise { - election, - p.ballotStyleId, - p.votes, -- p.store.getPrintCalibration() -+ p.store.getPrintCalibration(), -+ undefined - ); - - return p.printer.print({ -diff --git a/apps/scan/backend/src/server.ts b/apps/scan/backend/src/server.ts -index 3c995d047..ae5b91883 100644 ---- a/apps/scan/backend/src/server.ts -+++ b/apps/scan/backend/src/server.ts -@@ -90,28 +90,10 @@ export async function start({ - nodeEnv: NODE_ENV, - }); - -- if (audioInfo.usb) { -- const resultDefaultAudio = await setDefaultAudio(audioInfo.usb.name, { -- logger, -- nodeEnv: NODE_ENV, -- }); -- resultDefaultAudio.assertOk('unable to set USB audio as default output'); -- -- // Screen reader volume levels are calibrated against a maximum system -- // volume setting: -- const resultVolume = await setAudioVolume({ -- logger, -- nodeEnv: NODE_ENV, -- sinkName: audioInfo.usb.name, -- volumePct: 100, -- }); -- resultVolume.assertOk('unable to set USB audio volume'); -- } else { -- void logger.logAsCurrentRole(LogEventId.AudioDeviceMissing, { -- message: 'USB audio device not detected.', -- disposition: 'failure', -- }); -- } -+ void logger.logAsCurrentRole(LogEventId.AudioDeviceMissing, { -+ message: 'USB audio device not detected.', -+ disposition: 'failure', -+ }); - - const audioPlayer = new AudioPlayer(NODE_ENV, logger, audioInfo.builtin.name); - -diff --git a/libs/backend/src/system_call/get_audio_info.ts b/libs/backend/src/system_call/get_audio_info.ts -index b00654cf1..1499c0a07 100644 ---- a/libs/backend/src/system_call/get_audio_info.ts -+++ b/libs/backend/src/system_call/get_audio_info.ts -@@ -31,11 +31,17 @@ const PactlSinkListSchema = z.array( - }) - ); - -+const MOCK_AUDIO_INFO: AudioInfo = { -+ builtin: { headphonesActive: false, name: 'mock.builtin.stereo' }, -+}; -+ - /** Get current system audio status. */ - export async function getAudioInfo(ctx: { - logger: Logger; - nodeEnv: typeof NODE_ENV; - }): Promise { -+ return MOCK_AUDIO_INFO; -+ - const { logger, nodeEnv } = ctx; - let errorOutput: string; - let commandOutput: string; -diff --git a/libs/hmpb/src/marking.test.ts b/libs/hmpb/src/marking.test.ts -index 025c6e183..6c01ee21f 100644 ---- a/libs/hmpb/src/marking.test.ts -+++ b/libs/hmpb/src/marking.test.ts -@@ -26,7 +26,8 @@ test('places marks consistently', async () => { - election.unsafeUnwrap(), - fixture.ballotStyleId, - fixture.votes, -- { offsetMmX: 0, offsetMmY: 0 } -+ { offsetMmX: 0, offsetMmY: 0 }, -+ undefined - ); - - const ballotBuf = fs.readFileSync(fixture.blankBallotPath); -diff --git a/libs/hmpb/src/marking.ts b/libs/hmpb/src/marking.ts -index 4bcd1f0e8..edc6043b3 100644 ---- a/libs/hmpb/src/marking.ts -+++ b/libs/hmpb/src/marking.ts -@@ -10,7 +10,7 @@ import { - import fontKit from '@pdf-lib/fontkit'; - import fs from 'node:fs'; - --import { assert } from '@votingworks/basics'; -+import { assert, throwIllegalValue } from '@votingworks/basics'; - import { - ballotPaperDimensions, - Candidate, -@@ -60,6 +60,13 @@ const markSizeHalf = [markSize[0] * 0.5, markSize[1] * 0.5] as const; - const writeInFontSizeDefault = 12; - const writeInFontSizeReduced = 10; - -+export type DrawCallbackResult = 'draw' | 'ignore'; -+ -+export interface DrawCallback { -+ (type: 'bubble', position: GridPosition, vote: Vote[number]): DrawCallbackResult; -+ (type: 'write-in-text', position: GridPosition, vote: Vote[number]): DrawCallbackResult; -+} -+ - /** - * Generates a PDF with bubble marks in the expected positions for the given - * ballot style and corresponding votes. -@@ -74,7 +81,8 @@ export async function generateMarkOverlay( - ballotStyleId: string, - votes: VotesDict, - calibration: PrintCalibration, -- baseBallotPdf?: Uint8Array -+ baseBallotPdf: Uint8Array | undefined, -+ onDraw: DrawCallback = () => 'draw' - ): Promise { - assert( - election.gridLayouts, -@@ -117,7 +125,7 @@ export async function generateMarkOverlay( - assert( - basePageSize.width === pageSize[0] && basePageSize.height === pageSize[1], - `base PDF size ([${basePageSize.width},${basePageSize.height}]) does ` + -- `not match expected (${pageSize})` -+ `not match expected (${pageSize})` - ); - } - -@@ -153,10 +161,23 @@ export async function generateMarkOverlay( - ]; - - // Draw bubble mark using pdf-lib -- bubbleMark(page, [ -- bubbleCenter[0] - markSizeHalf[0], -- pageSize[1] - bubbleCenter[1] + markSizeHalf[1], -- ]); -+ const bubbleDrawResult = onDraw('bubble', pos, mark.vote); -+ switch (bubbleDrawResult) { -+ case 'ignore': { -+ break; -+ } -+ -+ case 'draw': { -+ bubbleMark(page, [ -+ bubbleCenter[0] - markSizeHalf[0], -+ pageSize[1] - bubbleCenter[1] + markSizeHalf[1], -+ ]); -+ break; -+ } -+ -+ default: -+ throwIllegalValue(bubbleDrawResult) -+ } - - if (!mark.writeInName) continue; - -@@ -178,12 +199,25 @@ export async function generateMarkOverlay( - - origin[1] += 0.5 * (areaSize[1] - fontSize); - -- page.drawText(name, { -- font: fontRobotoBold, -- size: fontSize, -- x: origin[0], -- y: pageSize[1] - origin[1] - fontSize, -- }); -+ const writeInTextDrawResult = onDraw('write-in-text', pos, mark.vote); -+ switch (writeInTextDrawResult) { -+ case 'ignore': { -+ break; -+ } -+ -+ case 'draw': { -+ page.drawText(name, { -+ font: fontRobotoBold, -+ size: fontSize, -+ x: origin[0], -+ y: pageSize[1] - origin[1] - fontSize, -+ }); -+ break; -+ } -+ -+ default: -+ throwIllegalValue(writeInTextDrawResult) -+ } - } - - // Make sure we have an even number of pages, to match print order of the base -@@ -276,8 +310,8 @@ function bubbleMark(page: PDFPage, originTopLeft: [number, number]): void { - } - - type MarkInfo = -- | { writeInName?: undefined } -- | { writeInArea: Rect; writeInName: string }; -+ | { vote: Vote[number]; writeInName?: undefined } -+ | { vote: Vote[number]; writeInArea: Rect; writeInName: string }; - - /** - * Determines if this grid position should be marked based on the votes. -@@ -297,7 +331,7 @@ function markInfo( - // Handle yes/no votes - if (contest.type === 'yesno') { - assert(gridPos.type === 'option'); -- if (vote === gridPos.optionId) return {}; -+ if (vote === gridPos.optionId) return { vote }; - continue; - } - // For candidate contests only -@@ -306,6 +340,7 @@ function markInfo( - if (gridPos.type === 'write-in') { - if (gridPos.writeInIndex === candidateVote.writeInIndex) { - return { -+ vote, - writeInArea: gridPos.writeInArea, - writeInName: candidateVote.name, - }; -@@ -315,7 +350,7 @@ function markInfo( - } - - if (voteMatchesGridPosition(candidateVote, gridPos, layout.gridPositions)) { -- return {}; -+ return { vote }; - } - } - -diff --git a/script/bootstrap b/script/bootstrap -index 1a8304cfc..09defe092 100755 ---- a/script/bootstrap -+++ b/script/bootstrap -@@ -2,17 +2,14 @@ - - set -euo pipefail - --local_user=`logname` --local_user_home_dir=$( getent passwd "${local_user}" | cut -d: -f6 ) -- - # Make sure PATH includes cargo and /sbin --export PATH="${local_user_home_dir}/.cargo/bin:${PATH}:/sbin/" -+export PATH="${HOME}/.cargo/bin:${PATH}:/sbin/" - --DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" -+DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" - - for app in ${DIR}/../apps/*/frontend ${DIR}/../apps/*/backend; do - if [ -d "${app}" ]; then -- make -C "${app}" bootstrap -+ make -C "${app}" -n bootstrap && make -C "${app}" bootstrap - fi - done -