Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
995a084
feat: support vxsuite v4.0 and v4.1
eventualbuddha Jul 7, 2026
f20ccb1
ci: pin pnpm to 10.34.1 for Node 20 compatibility
eventualbuddha Jul 7, 2026
1bd5263
fix(ci): format package.json and pre-fetch vxsuite Rust crates
eventualbuddha Jul 7, 2026
e8430f7
fix(ci): drop --offline from vxsuite Rust addon builds
eventualbuddha Jul 7, 2026
4e29f51
fix: correct VxSuite backend port and force clean vxsuite rebuild
eventualbuddha Jul 7, 2026
d504101
fix: point mock-state paths at .mock-state/<NODE_ENV>
eventualbuddha Jul 7, 2026
fad3984
fix(patch): mock setAudioVolume too (bypasses pactl wrapper)
eventualbuddha Jul 7, 2026
2f58390
fix: adapt to VxSuite's queue-based mock PDI scanner API
eventualbuddha Jul 7, 2026
2c139dd
fix: retry navigation and free both ports on app restart
eventualbuddha Jul 7, 2026
997f631
fix: build vxsuite with its pinned pnpm; clear ports before app start
eventualbuddha Jul 7, 2026
04dc81a
fix: reliable net-based port checks; bust cache; enable CI debug
eventualbuddha Jul 7, 2026
299e44d
fix: kill app process group so ports free on restart
eventualbuddha Jul 8, 2026
dc85ea9
ci: bump image to cimg-debian12:4.5.0 (Node 20.19) for v4.1
eventualbuddha Jul 8, 2026
81b27d6
fix: version-specific mock USB data path (v4.1 nests under disk name)
eventualbuddha Jul 8, 2026
d7bac76
test: real v4.1-rendered ballot fixture (replaces converted one)
eventualbuddha Jul 8, 2026
fdd8c20
fix: open CVR import via "Load" button on v4.1 tally screen
eventualbuddha Jul 8, 2026
8e283df
fix: keep getByText for v4.0 CVR opener (fix regression)
eventualbuddha Jul 8, 2026
eec7ead
fix: target v4.1 CVR "Load" button by class; dump page on failure
eventualbuddha Jul 8, 2026
474ef07
fix: exclude v4.1 'ballots-cast' metric from tally CSV validation
eventualbuddha Jul 8, 2026
5d58c88
feat: support primary elections in tally workflow
eventualbuddha Jul 9, 2026
f64cf22
fix: derive scan acceptance expectations from adjudication reasons
eventualbuddha Jul 9, 2026
d5e27d0
feat: support castable overvotes; add systemSettings override
eventualbuddha Jul 9, 2026
cd73240
test: add v4.1 primary and overvote QA jobs
eventualbuddha Jul 9, 2026
545cb61
fix: retry poll-worker card insertion when closing polls
eventualbuddha Jul 9, 2026
42d8653
fix: parse integer CLI options with an explicit base
eventualbuddha Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 77 additions & 26 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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-<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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .node-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
20.16.0
20.19.0
35 changes: 25 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,12 @@ pnpm start run --config my-config.json
pnpm start run [options]

Options:
-c, --config <path> Path to configuration file
-s, --save-config <path> Save interactive selections to config file
-o, --output <dir> Override output directory
-r, --ref <ref> Override VxSuite tag/branch/rev
-e, --election <path> Override election source path
--headless Run browser in headless mode (default)
--no-headless Run browser in headed mode for debugging
-c, --config <path> Path to configuration file
-o, --output <dir> Override output directory
--vxsuite-version <version> Override VxSuite version (e.g. v4.0, v4.1)
-e, --election <path> Override election source path
--headless Run browser in headless mode (default)
--no-headless Run browser in headed mode for debugging
```

## Configuration
Expand All @@ -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"
Expand All @@ -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,
Expand Down Expand Up @@ -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-<version>.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

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"oxlint-tsgolint": "^0.9.1",
"vitest": "^4.0.16"
},
"packageManager": "pnpm@10.34.1",
"engines": {
"node": ">=20.0.0"
}
Expand Down
39 changes: 24 additions & 15 deletions src/apps/env-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
* Environment configuration for mock hardware
*/

import assert from 'node:assert';

/**
* Environment variables to enable mock hardware
*/
Expand All @@ -28,14 +26,21 @@ export const MOCK_ENV_VARS: Record<string, string> = {
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: `<repo>/.mock-state/<NODE_ENV>/` (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
*/
export function getMockEnvironment(): NodeJS.ProcessEnv {
return {
...process.env,
...MOCK_ENV_VARS,
NODE_ENV: 'development',
NODE_ENV: MOCK_NODE_ENV,
};
}

Expand All @@ -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;
}
Loading