From 930f06de1f0a22c8c90b90191ef64d8249b4c268 Mon Sep 17 00:00:00 2001
From: Jonah Kagan
Date: Thu, 15 Dec 2022 22:26:38 -0800
Subject: [PATCH] Convert VxScan API to Grout (#2854)
---
.circleci/config.yml | 23 +
apps/vx-scan/backend/jest.config.js | 8 +-
apps/vx-scan/backend/package.json | 5 +
apps/vx-scan/backend/src/app.ts | 566 ++++++---------
apps/vx-scan/backend/src/app_config.test.ts | 91 +--
apps/vx-scan/backend/src/app_scan.test.ts | 610 ++++++++---------
apps/vx-scan/backend/src/index.ts | 2 +
apps/vx-scan/backend/src/store.ts | 4 +-
.../backend/test/helpers/app_helpers.ts | 125 ++--
apps/vx-scan/backend/tsconfig.build.json | 4 +-
apps/vx-scan/backend/tsconfig.json | 2 +
apps/vx-scan/frontend/.vscode/settings.json | 16 -
apps/vx-scan/frontend/jest.config.js | 4 +-
apps/vx-scan/frontend/package.json | 3 +
.../vx-scan/frontend/prodserver/setupProxy.js | 1 +
apps/vx-scan/frontend/src/api/api.ts | 16 +
apps/vx-scan/frontend/src/api/config.test.ts | 220 +-----
apps/vx-scan/frontend/src/api/config.ts | 143 +---
apps/vx-scan/frontend/src/api/scan.test.ts | 8 -
apps/vx-scan/frontend/src/api/scan.ts | 30 -
apps/vx-scan/frontend/src/app.test.tsx | 647 ++++++++----------
apps/vx-scan/frontend/src/app.tsx | 26 +-
apps/vx-scan/frontend/src/app_root.tsx | 41 +-
.../src/app_tally_report_paths.test.tsx | 440 ++++++------
.../frontend/src/app_unhappy_paths.test.tsx | 206 +++---
.../calibrate_scanner_modal.test.tsx | 147 ++--
.../components/calibrate_scanner_modal.tsx | 9 +-
.../components/export_backup_modal.test.tsx | 148 ++--
.../src/components/export_backup_modal.tsx | 44 +-
.../hooks/use_precinct_scanner_status.test.ts | 81 ---
.../use_precinct_scanner_status.test.tsx | 75 ++
.../src/hooks/use_precinct_scanner_status.ts | 5 +-
.../screens/election_manager_screen.test.tsx | 46 +-
.../src/screens/scan_warning_screen.test.tsx | 55 +-
.../src/screens/scan_warning_screen.tsx | 25 +-
.../screens/unconfigured_election_screen.tsx | 4 +-
.../frontend/test/helpers/mock_api_client.ts | 71 ++
.../frontend/test/helpers/mock_config.ts | 102 ---
apps/vx-scan/frontend/tsconfig.json | 3 +
libs/grout/.eslintignore | 3 +-
libs/grout/README.md | 34 +
libs/grout/package.json | 3 -
libs/grout/src/client.ts | 29 +-
libs/grout/src/grout.test.ts | 46 +-
libs/grout/src/index.ts | 1 -
libs/grout/test-utils/.eslintignore | 4 +
libs/grout/test-utils/.eslintrc.json | 3 +
libs/grout/test-utils/jest.config.js | 8 +
libs/grout/test-utils/package.json | 63 ++
libs/grout/test-utils/src/index.ts | 2 +
.../{ => test-utils}/src/mock_client.test.ts | 3 +-
.../grout/{ => test-utils}/src/mock_client.ts | 2 +-
libs/grout/test-utils/tsconfig.build.json | 17 +
libs/grout/test-utils/tsconfig.json | 18 +
libs/grout/tsconfig.build.json | 1 -
libs/grout/tsconfig.json | 1 -
pnpm-lock.yaml | 74 +-
pnpm-workspace.yaml | 1 +
.../src/validate-monorepo/validation/index.ts | 6 +-
tsconfig.shared.json | 2 +-
vxsuite.code-workspace | 4 +
61 files changed, 2005 insertions(+), 2376 deletions(-)
create mode 100644 apps/vx-scan/frontend/src/api/api.ts
delete mode 100644 apps/vx-scan/frontend/src/hooks/use_precinct_scanner_status.test.ts
create mode 100644 apps/vx-scan/frontend/src/hooks/use_precinct_scanner_status.test.tsx
create mode 100644 apps/vx-scan/frontend/test/helpers/mock_api_client.ts
delete mode 100644 apps/vx-scan/frontend/test/helpers/mock_config.ts
create mode 100644 libs/grout/test-utils/.eslintignore
create mode 100644 libs/grout/test-utils/.eslintrc.json
create mode 100644 libs/grout/test-utils/jest.config.js
create mode 100644 libs/grout/test-utils/package.json
create mode 100644 libs/grout/test-utils/src/index.ts
rename libs/grout/{ => test-utils}/src/mock_client.test.ts (97%)
rename libs/grout/{ => test-utils}/src/mock_client.ts (97%)
create mode 100644 libs/grout/test-utils/tsconfig.build.json
create mode 100644 libs/grout/test-utils/tsconfig.json
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 8ac3eb981..cc5849665 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -546,6 +546,28 @@ jobs:
- store_test_results:
path: libs/grout/reports/
+ test-libs-grout-test-utils:
+ executor: nodejs
+ resource_class: xlarge
+ steps:
+ - checkout-and-install
+ - run:
+ name: Build
+ command: |
+ pnpm --dir libs/grout/test-utils build
+ - run:
+ name: Lint
+ command: |
+ pnpm --dir libs/grout/test-utils lint
+ - run:
+ name: Test
+ command: |
+ pnpm --dir libs/grout/test-utils test
+ environment:
+ JEST_JUNIT_OUTPUT_DIR: ./reports/
+ - store_test_results:
+ path: libs/grout/test-utils/reports/
+
test-libs-image-utils:
executor: nodejs
resource_class: xlarge
@@ -846,6 +868,7 @@ workflows:
- test-libs-eslint-plugin-vx
- test-libs-fixtures
- test-libs-grout
+ - test-libs-grout-test-utils
- test-libs-image-utils
- test-libs-logging
- test-libs-plustek-sdk
diff --git a/apps/vx-scan/backend/jest.config.js b/apps/vx-scan/backend/jest.config.js
index f4a298727..316de0601 100644
--- a/apps/vx-scan/backend/jest.config.js
+++ b/apps/vx-scan/backend/jest.config.js
@@ -19,10 +19,10 @@ module.exports = {
],
coverageThreshold: {
global: {
- statements: 86,
- branches: 72,
- lines: 86,
- functions: 89,
+ statements: 89,
+ branches: 78,
+ functions: 90,
+ lines: 89,
},
},
};
diff --git a/apps/vx-scan/backend/package.json b/apps/vx-scan/backend/package.json
index 9825692fc..145d0ee46 100644
--- a/apps/vx-scan/backend/package.json
+++ b/apps/vx-scan/backend/package.json
@@ -2,6 +2,8 @@
"name": "@votingworks/vx-scan-backend",
"version": "0.1.0",
"private": true,
+ "main": "build/index.js",
+ "types": "build/index.d.ts",
"files": [
"build",
"bin",
@@ -42,6 +44,7 @@
"@votingworks/data": "workspace:*",
"@votingworks/db": "workspace:*",
"@votingworks/fixtures": "workspace:*",
+ "@votingworks/grout": "workspace:*",
"@votingworks/image-utils": "workspace:*",
"@votingworks/logging": "workspace:*",
"@votingworks/plustek-sdk": "workspace:*",
@@ -79,6 +82,7 @@
"@types/luxon": "^1.26.5",
"@types/multer": "^1.4.7",
"@types/node": "16.11.29",
+ "@types/node-fetch": "^2.6.2",
"@types/supertest": "^2.0.10",
"@types/tmp": "^0.2.0",
"@types/uuid": "^8.3.0",
@@ -98,6 +102,7 @@
"jest-watch-typeahead": "^0.6.4",
"lint-staged": "^10.5.3",
"nock": "^13.1.0",
+ "node-fetch": "^2.6.0",
"nodemon": "^2.0.20",
"prettier": "^2.6.2",
"sort-package-json": "^1.50.0",
diff --git a/apps/vx-scan/backend/src/app.ts b/apps/vx-scan/backend/src/app.ts
index b7c117d63..85e8b2deb 100644
--- a/apps/vx-scan/backend/src/app.ts
+++ b/apps/vx-scan/backend/src/app.ts
@@ -1,10 +1,15 @@
-import { ErrorsResponse, OkResponse, Scan } from '@votingworks/api';
+import { Scan } from '@votingworks/api';
import { pdfToImages } from '@votingworks/image-utils';
+import * as grout from '@votingworks/grout';
import { LogEventId, Logger } from '@votingworks/logging';
import {
BallotPageLayoutSchema,
BallotPageLayoutWithImage,
- safeParse,
+ MarkThresholds,
+ ok,
+ PollsState,
+ PrecinctSelection,
+ Result,
safeParseElectionDefinition,
safeParseJson,
} from '@votingworks/types';
@@ -14,7 +19,6 @@ import {
generateFilenameForScanningResults,
singlePrecinctSelectionFor,
} from '@votingworks/utils';
-import { Buffer } from 'buffer';
import express, { Application } from 'express';
import * as fs from 'fs/promises';
import multer from 'multer';
@@ -31,29 +35,17 @@ const debug = rootDebug.extend('app');
type NoParams = never;
-export function buildApp(
+function buildApi(
machine: PrecinctScannerStateMachine,
interpreter: PrecinctScannerInterpreter,
workspace: Workspace,
logger: Logger
-): Application {
+) {
const { store } = workspace;
-
- const app: Application = express();
- const upload = multer({
- storage: multer.diskStorage({
- destination: workspace.uploadsPath,
- }),
- });
-
- app.use(express.raw());
- app.use(express.json({ limit: '5mb', type: 'application/json' }));
- app.use(express.urlencoded({ extended: false }));
-
- app.get(
- '/precinct-scanner/config',
- (_request, response) => {
- response.json({
+ return grout.createApi({
+ // eslint-disable-next-line @typescript-eslint/require-await
+ async getConfig(): Promise {
+ return {
electionDefinition: store.getElectionDefinition(),
precinctSelection: store.getPrecinctSelection(),
markThresholdOverrides: store.getMarkThresholdOverrides(),
@@ -62,259 +54,109 @@ export function buildApp(
pollsState: store.getPollsState(),
ballotCountWhenBallotBagLastReplaced:
store.getBallotCountWhenBallotBagLastReplaced(),
- });
- }
- );
-
- app.patch<
- NoParams,
- Scan.PatchElectionConfigResponse,
- Scan.PatchElectionConfigRequest
- >('/precinct-scanner/config/election', (request, response) => {
- const { body } = request;
-
- if (!Buffer.isBuffer(body)) {
- response.status(400).json({
- status: 'error',
- errors: [
- {
- type: 'invalid-value',
- message: `expected content type to be application/octet-stream, got ${request.header(
- 'content-type'
- )}`,
- },
- ],
- });
- return;
- }
-
- const bodyParseResult = safeParseElectionDefinition(
- new TextDecoder('utf-8', { fatal: false }).decode(body)
- );
-
- if (bodyParseResult.isErr()) {
- const error = bodyParseResult.err();
- response.status(400).json({
- status: 'error',
- errors: [
- {
- type: error.name,
- message: error.message,
- },
- ],
- });
- return;
- }
-
- const electionDefinition = bodyParseResult.ok();
- store.setElection(electionDefinition.electionData);
- // If the election has only one precinct, set it automatically
- if (electionDefinition.election.precincts.length === 1) {
- store.setPrecinctSelection(
- singlePrecinctSelectionFor(electionDefinition.election.precincts[0].id)
+ };
+ },
+
+ // eslint-disable-next-line @typescript-eslint/require-await
+ async setElection(input: {
+ // We transmit and store the election definition as a string, not as a
+ // JSON object, since it will later be hashed to match the election hash
+ // in ballot QR codes. Since the original hash was made from the string,
+ // the most reliable way to get the same hash is to use the same string.
+ electionData: string;
+ }): Promise {
+ const parseResult = safeParseElectionDefinition(input.electionData);
+ const electionDefinition = parseResult.assertOk(
+ 'Invalid election definition'
);
- }
- response.json({ status: 'ok' });
- });
-
- app.delete(
- '/precinct-scanner/config/election',
- (request, response) => {
+ store.setElection(electionDefinition.electionData);
+ // If the election has only one precinct, set it automatically
+ if (electionDefinition.election.precincts.length === 1) {
+ store.setPrecinctSelection(
+ singlePrecinctSelectionFor(
+ electionDefinition.election.precincts[0].id
+ )
+ );
+ }
+ },
+
+ // eslint-disable-next-line @typescript-eslint/require-await
+ async unconfigureElection(input: {
+ ignoreBackupRequirement?: boolean;
+ }): Promise {
+ assert(
+ input.ignoreBackupRequirement || store.getCanUnconfigure(),
+ 'Attempt to unconfigure without backup'
+ );
+ interpreter.unconfigure();
+ workspace.reset();
+ },
+
+ // eslint-disable-next-line @typescript-eslint/require-await
+ async setPrecinctSelection(input: {
+ precinctSelection: PrecinctSelection;
+ }): Promise {
+ assert(
+ store.getBallotsCounted() === 0,
+ 'Attempt to change precinct selection after ballots have been cast'
+ );
+ store.setPrecinctSelection(input.precinctSelection);
+ workspace.resetElectionSession();
+ },
+
+ // eslint-disable-next-line @typescript-eslint/require-await
+ async setMarkThresholdOverrides(input: {
+ markThresholdOverrides?: MarkThresholds;
+ }): Promise {
+ store.setMarkThresholdOverrides(input.markThresholdOverrides);
+ },
+
+ // eslint-disable-next-line @typescript-eslint/require-await
+ async setIsSoundMuted(input: { isSoundMuted: boolean }): Promise {
+ store.setIsSoundMuted(input.isSoundMuted);
+ },
+
+ // eslint-disable-next-line @typescript-eslint/require-await
+ async setTestMode(input: { isTestMode: boolean }): Promise {
+ workspace.resetElectionSession();
+ store.setTestMode(input.isTestMode);
+ },
+
+ async setPollsState(input: { pollsState: PollsState }): Promise {
+ const previousPollsState = store.getPollsState();
+ const newPollsState = input.pollsState;
+
+ // Start new batch if opening polls, end batch if pausing or closing polls
if (
- !store.getCanUnconfigure() &&
- request.query['ignoreBackupRequirement'] !== 'true'
+ newPollsState === 'polls_open' &&
+ previousPollsState !== 'polls_open'
) {
- response.status(400).json({
- status: 'error',
- errors: [
- {
- type: 'no-backup',
- message:
- 'cannot unconfigure an election that has not been backed up',
- },
- ],
+ const batchId = store.addBatch();
+ await logger.log(LogEventId.ScannerBatchStarted, 'system', {
+ disposition: 'success',
+ message:
+ 'New scanning batch started due to polls being opened or voting being resumed.',
+ batchId,
+ });
+ } else if (
+ newPollsState !== 'polls_open' &&
+ previousPollsState === 'polls_open'
+ ) {
+ const ongoingBatchId = store.getOngoingBatchId();
+ assert(typeof ongoingBatchId === 'string');
+ store.finishBatch({ batchId: ongoingBatchId });
+ await logger.log(LogEventId.ScannerBatchEnded, 'system', {
+ disposition: 'success',
+ message:
+ 'Current scanning batch ended due to polls being closed or voting being paused.',
+ batchId: ongoingBatchId,
});
- return;
}
- interpreter.unconfigure();
- workspace.reset();
- response.json({ status: 'ok' });
- }
- );
-
- app.patch<
- NoParams,
- Scan.PatchPrecinctSelectionConfigResponse,
- Scan.PatchPrecinctSelectionConfigRequest
- >('/precinct-scanner/config/precinct', (request, response) => {
- const bodyParseResult = safeParse(
- Scan.PatchPrecinctSelectionConfigRequestSchema,
- request.body
- );
-
- if (bodyParseResult.isErr()) {
- const error = bodyParseResult.err();
- response.status(400).json({
- status: 'error',
- errors: [{ type: error.name, message: error.message }],
- });
- return;
- }
-
- if (store.getBallotsCounted() > 0) {
- response.status(400).json({
- status: 'error',
- errors: [
- {
- type: 'ballots-cast',
- message:
- 'cannot change the precinct selection if ballots have been cast',
- },
- ],
- });
- return;
- }
-
- store.setPrecinctSelection(bodyParseResult.ok().precinctSelection);
- workspace.resetElectionSession();
- response.json({ status: 'ok' });
- });
-
- app.patch<
- NoParams,
- Scan.PatchMarkThresholdOverridesConfigResponse,
- Scan.PatchMarkThresholdOverridesConfigRequest
- >('/precinct-scanner/config/markThresholdOverrides', (request, response) => {
- const bodyParseResult = safeParse(
- Scan.PatchMarkThresholdOverridesConfigRequestSchema,
- request.body
- );
-
- if (bodyParseResult.isErr()) {
- const error = bodyParseResult.err();
- response.status(400).json({
- status: 'error',
- errors: [{ type: error.name, message: error.message }],
- });
- return;
- }
-
- store.setMarkThresholdOverrides(
- bodyParseResult.ok().markThresholdOverrides
- );
-
- response.json({ status: 'ok' });
- });
-
- app.delete(
- '/precinct-scanner/config/markThresholdOverrides',
- (_request, response) => {
- store.setMarkThresholdOverrides(undefined);
- response.json({ status: 'ok' });
- }
- );
-
- app.patch<
- NoParams,
- Scan.PatchIsSoundMutedConfigResponse,
- Scan.PatchIsSoundMutedConfigRequest
- >('/precinct-scanner/config/isSoundMuted', (request, response) => {
- const bodyParseResult = safeParse(
- Scan.PatchIsSoundMutedConfigRequestSchema,
- request.body
- );
-
- if (bodyParseResult.isErr()) {
- const error = bodyParseResult.err();
- response.status(400).json({
- status: 'error',
- errors: [{ type: error.name, message: error.message }],
- });
- return;
- }
-
- store.setIsSoundMuted(bodyParseResult.ok().isSoundMuted);
- response.json({ status: 'ok' });
- });
-
- app.patch<
- NoParams,
- Scan.PatchTestModeConfigResponse,
- Scan.PatchTestModeConfigRequest
- >('/precinct-scanner/config/testMode', (request, response) => {
- const bodyParseResult = safeParse(
- Scan.PatchTestModeConfigRequestSchema,
- request.body
- );
-
- if (bodyParseResult.isErr()) {
- const error = bodyParseResult.err();
- response.status(400).json({
- status: 'error',
- errors: [{ type: error.name, message: error.message }],
- });
- return;
- }
-
- workspace.resetElectionSession();
- store.setTestMode(bodyParseResult.ok().testMode);
- response.json({ status: 'ok' });
- });
-
- app.patch<
- NoParams,
- Scan.PatchPollsStateResponse,
- Scan.PatchPollsStateRequest
- >('/precinct-scanner/config/polls', async (request, response) => {
- const bodyParseResult = safeParse(
- Scan.PatchPollsStateRequestSchema,
- request.body
- );
-
- if (bodyParseResult.isErr()) {
- const error = bodyParseResult.err();
- response.status(400).json({
- status: 'error',
- errors: [{ type: error.name, message: error.message }],
- });
- return;
- }
+ store.setPollsState(newPollsState);
+ },
- const previousPollsState = store.getPollsState();
- const newPollsState = bodyParseResult.ok().pollsState;
-
- // Start new batch if opening polls, end batch if pausing or closing polls
- if (newPollsState === 'polls_open' && previousPollsState !== 'polls_open') {
- const batchId = store.addBatch();
- await logger.log(LogEventId.ScannerBatchStarted, 'system', {
- disposition: 'success',
- message:
- 'New scanning batch started due to polls being opened or voting being resumed.',
- batchId,
- });
- } else if (
- newPollsState !== 'polls_open' &&
- previousPollsState === 'polls_open'
- ) {
- const ongoingBatchId = store.getOngoingBatchId();
- assert(typeof ongoingBatchId === 'string');
- store.finishBatch({ batchId: ongoingBatchId });
- await logger.log(LogEventId.ScannerBatchEnded, 'system', {
- disposition: 'success',
- message:
- 'Current scanning batch ended due to polls being closed or voting being paused.',
- batchId: ongoingBatchId,
- });
- }
-
- store.setPollsState(bodyParseResult.ok().pollsState);
- response.json({ status: 'ok' });
- });
-
- app.patch(
- '/precinct-scanner/config/ballotBagReplaced',
- async (_request, response) => {
+ async recordBallotBagReplaced(): Promise {
// If polls are open, we need to end current batch and start a new batch
if (store.getPollsState() === 'polls_open') {
const ongoingBatchId = store.getOngoingBatchId();
@@ -336,11 +178,95 @@ export function buildApp(
}
store.setBallotCountWhenBallotBagLastReplaced(store.getBallotsCounted());
- response.json({ status: 'ok' });
- }
+ },
+
+ async backupToUsbDrive(): Promise> {
+ const result = await backupToUsbDrive(store);
+ return result.isErr() ? result : ok();
+ },
+
+ // eslint-disable-next-line @typescript-eslint/require-await
+ async getScannerStatus(): Promise {
+ const machineStatus = machine.status();
+ const ballotsCounted = store.getBallotsCounted();
+ const canUnconfigure = store.getCanUnconfigure();
+ return {
+ ...machineStatus,
+ ballotsCounted,
+ canUnconfigure,
+ };
+ },
+
+ async scanBallot(): Promise {
+ assert(store.getPollsState() === 'polls_open');
+ const electionDefinition = store.getElectionDefinition();
+ const precinctSelection = store.getPrecinctSelection();
+ const layouts = await store.loadLayouts();
+ assert(electionDefinition);
+ assert(precinctSelection);
+ assert(layouts);
+ interpreter.configure({
+ electionDefinition,
+ precinctSelection,
+ layouts,
+ testMode: store.getTestMode(),
+ markThresholdOverrides: store.getMarkThresholdOverrides(),
+ ballotImagesPath: workspace.ballotImagesPath,
+ });
+ machine.scan();
+ },
+
+ // eslint-disable-next-line @typescript-eslint/require-await
+ async acceptBallot(): Promise {
+ machine.accept();
+ },
+
+ // eslint-disable-next-line @typescript-eslint/require-await
+ async returnBallot(): Promise {
+ machine.return();
+ },
+
+ async calibrate(): Promise {
+ const result = await machine.calibrate();
+ return result.isOk();
+ },
+ });
+}
+
+export type Api = ReturnType;
+
+export function buildApp(
+ machine: PrecinctScannerStateMachine,
+ interpreter: PrecinctScannerInterpreter,
+ workspace: Workspace,
+ logger: Logger
+): Application {
+ const { store } = workspace;
+
+ const app: Application = express();
+
+ const api = buildApi(machine, interpreter, workspace, logger);
+ app.use('/api', grout.buildRouter(api, express));
+
+ const deprecatedApiRouter = express.Router();
+
+ const upload = multer({
+ storage: multer.diskStorage({
+ destination: workspace.uploadsPath,
+ }),
+ });
+
+ deprecatedApiRouter.use(express.raw());
+ deprecatedApiRouter.use(
+ express.json({ limit: '5mb', type: 'application/json' })
);
+ deprecatedApiRouter.use(express.urlencoded({ extended: false }));
- app.post(
+ deprecatedApiRouter.post<
+ NoParams,
+ Scan.AddTemplatesResponse,
+ Scan.AddTemplatesRequest
+ >(
'/precinct-scanner/config/addTemplates',
upload.fields([
{ name: 'ballots' },
@@ -471,7 +397,7 @@ export function buildApp(
}
);
- app.post(
+ deprecatedApiRouter.post(
'/precinct-scanner/export',
async (request, response) => {
const skipImages = request.body?.skipImages;
@@ -499,103 +425,7 @@ export function buildApp(
}
);
- app.post(
- '/precinct-scanner/backup-to-usb-drive',
- async (_request, response) => {
- const result = await backupToUsbDrive(store);
-
- if (result.isErr()) {
- response.status(500).json({
- status: 'error',
- errors: [result.err()],
- });
- return;
- }
-
- response.json({ status: 'ok', paths: result.ok() });
- }
- );
-
- app.get(
- '/precinct-scanner/scanner/status',
- (_request, response) => {
- const machineStatus = machine.status();
- const ballotsCounted = store.getBallotsCounted();
- const canUnconfigure = store.getCanUnconfigure();
- response.json({
- ...machineStatus,
- ballotsCounted,
- canUnconfigure,
- });
- }
- );
-
- app.post(
- '/precinct-scanner/scanner/scan',
- async (_request, response) => {
- if (store.getPollsState() !== 'polls_open') {
- response.status(400).json({
- status: 'error',
- errors: [
- {
- type: 'polls-closed',
- message: 'cannot scan ballots while polls are closed',
- },
- ],
- });
- return;
- }
-
- const electionDefinition = store.getElectionDefinition();
- const precinctSelection = store.getPrecinctSelection();
- const layouts = await store.loadLayouts();
- assert(electionDefinition);
- assert(precinctSelection);
- assert(layouts);
- interpreter.configure({
- electionDefinition,
- precinctSelection,
- layouts,
- testMode: store.getTestMode(),
- markThresholdOverrides: store.getMarkThresholdOverrides(),
- ballotImagesPath: workspace.ballotImagesPath,
- });
-
- machine.scan();
- response.json({ status: 'ok' });
- }
- );
-
- app.post(
- '/precinct-scanner/scanner/accept',
- (_request, response) => {
- machine.accept();
- response.json({ status: 'ok' });
- }
- );
-
- app.post(
- '/precinct-scanner/scanner/return',
- (_request, response) => {
- machine.return();
- response.json({ status: 'ok' });
- }
- );
-
- app.post(
- '/precinct-scanner/scanner/calibrate',
- async (_request, response) => {
- const result = await machine.calibrate();
- if (result.isOk()) {
- response.json({ status: 'ok' });
- } else {
- response.json({
- status: 'error',
- errors: [{ type: 'error', message: result.err() }],
- });
- }
- }
- );
+ app.use(deprecatedApiRouter);
return app;
}
diff --git a/apps/vx-scan/backend/src/app_config.test.ts b/apps/vx-scan/backend/src/app_config.test.ts
index efbe21f30..9bc140b13 100644
--- a/apps/vx-scan/backend/src/app_config.test.ts
+++ b/apps/vx-scan/backend/src/app_config.test.ts
@@ -1,35 +1,31 @@
import { MockScannerClient } from '@votingworks/plustek-sdk';
import request from 'supertest';
-import { Application } from 'express';
import { electionMinimalExhaustiveSampleSinglePrecinctDefinition } from '@votingworks/fixtures';
-import { singlePrecinctSelectionFor } from '@votingworks/utils';
import { Scan } from '@votingworks/api';
import waitForExpect from 'wait-for-expect';
import { LogEventId } from '@votingworks/logging';
+import * as grout from '@votingworks/grout';
+import { singlePrecinctSelectionFor } from '@votingworks/utils';
import {
ballotImages,
configureApp,
createApp,
- get,
- patch,
- post,
postExportCvrs,
- setAppPrecinct,
- setPollsState,
waitForStatus,
} from '../test/helpers/app_helpers';
+import { Api } from './app';
jest.setTimeout(20_000);
async function scanBallot(
mockPlustek: MockScannerClient,
- app: Application,
+ apiClient: grout.Client,
initialBallotsCounted: number
) {
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'ready_to_scan',
ballotsCounted: initialBallotsCounted,
});
@@ -38,35 +34,34 @@ async function scanBallot(
type: 'ValidSheet',
};
- await post(app, '/precinct-scanner/scanner/scan');
- await waitForStatus(app, {
+ await apiClient.scanBallot();
+ await waitForStatus(apiClient, {
state: 'ready_to_accept',
interpretation,
ballotsCounted: initialBallotsCounted,
});
- await post(app, '/precinct-scanner/scanner/accept');
- await waitForStatus(app, {
+ await apiClient.acceptBallot();
+ await waitForStatus(apiClient, {
ballotsCounted: initialBallotsCounted + 1,
state: 'accepted',
interpretation,
});
// Wait for transition back to no paper
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'no_paper',
ballotsCounted: initialBallotsCounted + 1,
});
}
test("setting the election also sets precinct if there's only one", async () => {
- const { app } = await createApp();
- await patch(
- app,
- '/precinct-scanner/config/election',
- electionMinimalExhaustiveSampleSinglePrecinctDefinition.electionData
- );
- const response = await get(app, '/precinct-scanner/config');
- expect(response.body.precinctSelection).toMatchObject({
+ const { apiClient } = await createApp();
+ await apiClient.setElection({
+ electionData:
+ electionMinimalExhaustiveSampleSinglePrecinctDefinition.electionData,
+ });
+ const config = await apiClient.getConfig();
+ expect(config.precinctSelection).toMatchObject({
kind: 'SinglePrecinct',
precinctId: 'precinct-1',
});
@@ -74,9 +69,9 @@ test("setting the election also sets precinct if there's only one", async () =>
describe('POST /precinct-scanner/export', () => {
test('sets CVRs as backed up', async () => {
- const { app, workspace } = await createApp();
+ const { apiClient, app, workspace } = await createApp();
- await configureApp(app);
+ await configureApp(apiClient, app);
await request(app)
.post('/precinct-scanner/export')
.set('Accept', 'application/json')
@@ -88,43 +83,31 @@ describe('POST /precinct-scanner/export', () => {
});
});
-describe('PATCH /precinct-scanner/config/precinct', () => {
- test('will return error status if ballots have been cast', async () => {
- const { app, mockPlustek } = await createApp();
- await configureApp(app);
- await scanBallot(mockPlustek, app, 0);
-
- await request(app)
- .patch('/precinct-scanner/config/precinct')
- .set('Content-Type', 'application/json')
- .send({ precinctSelection: singlePrecinctSelectionFor('whatever') })
- .expect(400);
- });
-
- test('will reset polls to closed', async () => {
- const { app, workspace } = await createApp();
- await configureApp(app);
+test('setPrecinctSelection will reset polls to closed', async () => {
+ const { apiClient, app, workspace } = await createApp();
+ await configureApp(apiClient, app);
- workspace.store.setPollsState('polls_open');
- await setAppPrecinct(app, '21');
- expect(workspace.store.getPollsState()).toEqual('polls_closed_initial');
+ workspace.store.setPollsState('polls_open');
+ await apiClient.setPrecinctSelection({
+ precinctSelection: singlePrecinctSelectionFor('21'),
});
+ expect(workspace.store.getPollsState()).toEqual('polls_closed_initial');
});
test('ballot batching', async () => {
- const { app, mockPlustek, logger, workspace } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek, logger, workspace } = await createApp();
+ await configureApp(apiClient, app);
// Scan two ballots, which should have the same batch
- await scanBallot(mockPlustek, app, 0);
- await scanBallot(mockPlustek, app, 1);
+ await scanBallot(mockPlustek, apiClient, 0);
+ await scanBallot(mockPlustek, apiClient, 1);
let cvrs = await postExportCvrs(app);
expect(cvrs).toHaveLength(2);
const batch1Id = cvrs[0]._batchId;
expect(cvrs[1]._batchId).toEqual(batch1Id);
// Pause polls, which should stop the current batch
- await setPollsState(app, 'polls_paused');
+ await apiClient.setPollsState({ pollsState: 'polls_paused' });
await waitForExpect(() => {
expect(logger.log).toHaveBeenCalledWith(
LogEventId.ScannerBatchEnded,
@@ -139,7 +122,7 @@ test('ballot batching', async () => {
});
// Reopen polls, which should stop the current batch
- await setPollsState(app, 'polls_open');
+ await apiClient.setPollsState({ pollsState: 'polls_open' });
await waitForExpect(() => {
expect(logger.log).toHaveBeenCalledWith(
LogEventId.ScannerBatchStarted,
@@ -154,8 +137,8 @@ test('ballot batching', async () => {
});
// Confirm there is a new, second batch distinct from the first
- await scanBallot(mockPlustek, app, 2);
- await scanBallot(mockPlustek, app, 3);
+ await scanBallot(mockPlustek, apiClient, 2);
+ await scanBallot(mockPlustek, apiClient, 3);
cvrs = await postExportCvrs(app);
expect(cvrs).toHaveLength(4);
const batch2Id = cvrs[2]._batchId;
@@ -163,7 +146,7 @@ test('ballot batching', async () => {
expect(cvrs[3]._batchId).toEqual(batch2Id);
// Replace the ballot bag, which should create a new batch
- await patch(app, '/precinct-scanner/config/ballotBagReplaced');
+ await apiClient.recordBallotBagReplaced();
expect(workspace.store.getBallotCountWhenBallotBagLastReplaced()).toEqual(4);
await waitForExpect(() => {
expect(logger.log).toHaveBeenCalledWith(
@@ -189,8 +172,8 @@ test('ballot batching', async () => {
});
// Confirm there is a third batch, distinct from the second
- await scanBallot(mockPlustek, app, 4);
- await scanBallot(mockPlustek, app, 5);
+ await scanBallot(mockPlustek, apiClient, 4);
+ await scanBallot(mockPlustek, apiClient, 5);
cvrs = await postExportCvrs(app);
expect(cvrs).toHaveLength(6);
const batch3Id = cvrs[4]._batchId;
diff --git a/apps/vx-scan/backend/src/app_scan.test.ts b/apps/vx-scan/backend/src/app_scan.test.ts
index 45f2aa978..5001a5767 100644
--- a/apps/vx-scan/backend/src/app_scan.test.ts
+++ b/apps/vx-scan/backend/src/app_scan.test.ts
@@ -1,5 +1,4 @@
import { AdjudicationReason, err, ok } from '@votingworks/types';
-import request from 'supertest';
import waitForExpect from 'wait-for-expect';
import { Scan } from '@votingworks/api';
import { Logger } from '@votingworks/logging';
@@ -10,7 +9,6 @@ import {
configureApp,
createApp,
expectStatus,
- post,
postExportCvrs,
waitForStatus,
} from '../test/helpers/app_helpers';
@@ -87,35 +85,35 @@ function mockInterpretation(
}
test('configure and scan hmpb', async () => {
- const { app, mockPlustek, logger } = await createApp();
- await configureApp(app, { addTemplates: true });
+ const { apiClient, app, mockPlustek, logger } = await createApp();
+ await configureApp(apiClient, app, { addTemplates: true });
(
await mockPlustek.simulateLoadSheet(ballotImages.completeHmpb)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation: Scan.SheetInterpretation = {
type: 'ValidSheet',
};
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, { state: 'ready_to_accept', interpretation });
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, { state: 'ready_to_accept', interpretation });
- await post(app, '/precinct-scanner/scanner/accept');
- await expectStatus(app, {
+ await apiClient.acceptBallot();
+ await expectStatus(apiClient, {
state: 'accepting',
interpretation,
});
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'accepted',
interpretation,
ballotsCounted: 1,
});
// Test waiting for automatic transition back to no_paper
- await waitForStatus(app, { state: 'no_paper', ballotsCounted: 1 });
+ await waitForStatus(apiClient, { state: 'no_paper', ballotsCounted: 1 });
// Check the CVR
const cvrs = await postExportCvrs(app);
@@ -126,28 +124,28 @@ test('configure and scan hmpb', async () => {
});
test('configure and scan bmd ballot', async () => {
- const { app, mockPlustek, logger } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek, logger } = await createApp();
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation: Scan.SheetInterpretation = {
type: 'ValidSheet',
};
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, { state: 'ready_to_accept', interpretation });
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, { state: 'ready_to_accept', interpretation });
- await post(app, '/precinct-scanner/scanner/accept');
- await expectStatus(app, {
+ await apiClient.acceptBallot();
+ await expectStatus(apiClient, {
state: 'accepting',
interpretation,
});
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'accepted',
interpretation,
ballotsCounted: 1,
@@ -157,7 +155,7 @@ test('configure and scan bmd ballot', async () => {
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan', ballotsCounted: 1 });
+ await waitForStatus(apiClient, { state: 'ready_to_scan', ballotsCounted: 1 });
// Check the CVR
const cvrs = await postExportCvrs(app);
@@ -172,32 +170,32 @@ const needsReviewInterpretation: Scan.SheetInterpretation = {
};
test('ballot needs review - return', async () => {
- const { app, mockPlustek, workspace, logger } = await createApp();
- await configureApp(app, { addTemplates: true });
+ const { apiClient, app, mockPlustek, workspace, logger } = await createApp();
+ await configureApp(apiClient, app, { addTemplates: true });
(
await mockPlustek.simulateLoadSheet(ballotImages.unmarkedHmpb)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation = needsReviewInterpretation;
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, { state: 'needs_review', interpretation });
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, { state: 'needs_review', interpretation });
- await post(app, '/precinct-scanner/scanner/return');
- await expectStatus(app, {
+ await apiClient.returnBallot();
+ await expectStatus(apiClient, {
state: 'returning',
interpretation,
});
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'returned',
interpretation,
});
(await mockPlustek.simulateRemoveSheet()).unsafeUnwrap();
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'no_paper',
});
@@ -212,32 +210,32 @@ test('ballot needs review - return', async () => {
});
test('ballot needs review - accept', async () => {
- const { app, mockPlustek, logger } = await createApp();
- await configureApp(app, { addTemplates: true });
+ const { apiClient, app, mockPlustek, logger } = await createApp();
+ await configureApp(apiClient, app, { addTemplates: true });
(
await mockPlustek.simulateLoadSheet(ballotImages.unmarkedHmpb)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation = needsReviewInterpretation;
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, { state: 'needs_review', interpretation });
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, { state: 'needs_review', interpretation });
- await post(app, '/precinct-scanner/scanner/accept');
- await expectStatus(app, {
+ await apiClient.acceptBallot();
+ await expectStatus(apiClient, {
state: 'accepting_after_review',
interpretation,
});
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'accepted',
interpretation,
ballotsCounted: 1,
});
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'no_paper',
ballotsCounted: 1,
});
@@ -251,32 +249,32 @@ test('ballot needs review - accept', async () => {
// TODO test all the invalid ballot reasons?
test('invalid ballot rejected', async () => {
- const { app, mockPlustek, workspace, logger } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek, workspace, logger } = await createApp();
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.wrongElection)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation: Scan.SheetInterpretation = {
type: 'InvalidSheet',
reason: 'invalid_election_hash',
};
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, {
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, {
state: 'rejecting',
interpretation,
});
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'rejected',
interpretation,
});
(await mockPlustek.simulateRemoveSheet()).unsafeUnwrap();
- await waitForStatus(app, { state: 'no_paper' });
+ await waitForStatus(apiClient, { state: 'no_paper' });
// Check the CVR
const cvrs = await postExportCvrs(app);
@@ -289,38 +287,38 @@ test('invalid ballot rejected', async () => {
});
test('bmd ballot is rejected when scanned for wrong precinct', async () => {
- const { app, mockPlustek } = await createApp();
- await configureApp(app, { precinctId: '22' });
+ const { apiClient, app, mockPlustek } = await createApp();
+ await configureApp(apiClient, app, { precinctId: '22' });
// Ballot should be rejected when configured for the wrong precinct
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation: Scan.SheetInterpretation = {
type: 'InvalidSheet',
reason: 'invalid_precinct',
};
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, {
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, {
state: 'rejecting',
interpretation,
});
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'rejected',
interpretation,
});
(await mockPlustek.simulateRemoveSheet()).unsafeUnwrap();
- await waitForStatus(app, { state: 'no_paper' });
+ await waitForStatus(apiClient, { state: 'no_paper' });
});
test('bmd ballot is accepted if precinct is set for the right precinct', async () => {
- const { app, mockPlustek } = await createApp();
- await configureApp(app, { precinctId: '23' });
+ const { apiClient, app, mockPlustek } = await createApp();
+ await configureApp(apiClient, app, { precinctId: '23' });
// Configure for the proper precinct and verify the ballot scans
const validInterpretation: Scan.SheetInterpretation = {
@@ -330,49 +328,49 @@ test('bmd ballot is accepted if precinct is set for the right precinct', async (
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, {
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, {
state: 'ready_to_accept',
interpretation: validInterpretation,
});
});
test('hmpb ballot is rejected when scanned for wrong precinct', async () => {
- const { app, mockPlustek } = await createApp();
- await configureApp(app, { addTemplates: true, precinctId: '22' });
+ const { apiClient, app, mockPlustek } = await createApp();
+ await configureApp(apiClient, app, { addTemplates: true, precinctId: '22' });
// Ballot should be rejected when configured for the wrong precinct
(
await mockPlustek.simulateLoadSheet(ballotImages.completeHmpb)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation: Scan.SheetInterpretation = {
type: 'InvalidSheet',
reason: 'invalid_precinct',
};
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, {
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, {
state: 'rejecting',
interpretation,
});
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'rejected',
interpretation,
});
(await mockPlustek.simulateRemoveSheet()).unsafeUnwrap();
- await waitForStatus(app, { state: 'no_paper' });
+ await waitForStatus(apiClient, { state: 'no_paper' });
});
test('hmpb ballot is accepted if precinct is set for the right precinct', async () => {
- const { app, mockPlustek } = await createApp();
- await configureApp(app, { addTemplates: true, precinctId: '21' });
+ const { apiClient, app, mockPlustek } = await createApp();
+ await configureApp(apiClient, app, { addTemplates: true, precinctId: '21' });
// Configure for the proper precinct and verify the ballot scans
const validInterpretation: Scan.SheetInterpretation = {
@@ -382,150 +380,150 @@ test('hmpb ballot is accepted if precinct is set for the right precinct', async
(
await mockPlustek.simulateLoadSheet(ballotImages.completeHmpb)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, {
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, {
state: 'ready_to_accept',
interpretation: validInterpretation,
});
});
test('blank sheet ballot rejected', async () => {
- const { app, mockPlustek } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek } = await createApp();
+ await configureApp(apiClient, app);
(await mockPlustek.simulateLoadSheet(ballotImages.blankSheet)).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation: Scan.SheetInterpretation = {
type: 'InvalidSheet',
reason: 'unknown',
};
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, {
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, {
state: 'rejecting',
interpretation,
});
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'rejected',
interpretation,
});
(await mockPlustek.simulateRemoveSheet()).unsafeUnwrap();
- await waitForStatus(app, { state: 'no_paper' });
+ await waitForStatus(apiClient, { state: 'no_paper' });
});
test('scanner powered off while waiting for paper', async () => {
- const { app, mockPlustek } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek } = await createApp();
+ await configureApp(apiClient, app);
mockPlustek.simulatePowerOff();
- await waitForStatus(app, { state: 'disconnected' });
+ await waitForStatus(apiClient, { state: 'disconnected' });
mockPlustek.simulatePowerOn();
- await waitForStatus(app, { state: 'no_paper' });
+ await waitForStatus(apiClient, { state: 'no_paper' });
});
test('scanner powered off while scanning', async () => {
- const { app, mockPlustek } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek } = await createApp();
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
mockPlustek.simulatePowerOff();
- await waitForStatus(app, { state: 'disconnected' });
+ await waitForStatus(apiClient, { state: 'disconnected' });
mockPlustek.simulatePowerOn('jam');
- await waitForStatus(app, { state: 'jammed' });
+ await waitForStatus(apiClient, { state: 'jammed' });
});
test('scanner powered off while accepting', async () => {
- const { app, mockPlustek, interpreter } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek, interpreter } = await createApp();
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation: Scan.SheetInterpretation = {
type: 'ValidSheet',
};
mockInterpretation(interpreter, interpretation);
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, { state: 'ready_to_accept', interpretation });
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, { state: 'ready_to_accept', interpretation });
mockPlustek.simulatePowerOff();
- await post(app, '/precinct-scanner/scanner/accept');
- await waitForStatus(app, { state: 'disconnected' });
+ await apiClient.acceptBallot();
+ await waitForStatus(apiClient, { state: 'disconnected' });
mockPlustek.simulatePowerOn('ready_to_eject');
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'rejecting',
error: 'paper_in_back_after_reconnect',
});
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'rejected',
error: 'paper_in_back_after_reconnect',
});
});
test('scanner powered off after accepting', async () => {
- const { app, mockPlustek, interpreter } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek, interpreter } = await createApp();
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation: Scan.SheetInterpretation = {
type: 'ValidSheet',
};
mockInterpretation(interpreter, interpretation);
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, { state: 'ready_to_accept', interpretation });
- await post(app, '/precinct-scanner/scanner/accept');
- await waitForStatus(app, {
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, { state: 'ready_to_accept', interpretation });
+ await apiClient.acceptBallot();
+ await waitForStatus(apiClient, {
state: 'accepting',
interpretation,
});
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'accepted',
interpretation,
ballotsCounted: 1,
});
mockPlustek.simulatePowerOff();
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'disconnected',
ballotsCounted: 1,
});
mockPlustek.simulatePowerOn('no_paper');
- await waitForStatus(app, { state: 'no_paper', ballotsCounted: 1 });
+ await waitForStatus(apiClient, { state: 'no_paper', ballotsCounted: 1 });
});
test('scanner powered off while rejecting', async () => {
- const { app, mockPlustek, interpreter } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek, interpreter } = await createApp();
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.wrongElection)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation: Scan.SheetInterpretation = {
type: 'InvalidSheet',
@@ -533,148 +531,151 @@ test('scanner powered off while rejecting', async () => {
};
mockInterpretation(interpreter, interpretation);
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, {
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, {
state: 'rejecting',
interpretation,
});
mockPlustek.simulatePowerOff();
- await waitForStatus(app, { state: 'disconnected' });
+ await waitForStatus(apiClient, { state: 'disconnected' });
mockPlustek.simulatePowerOn('jam');
- await waitForStatus(app, { state: 'jammed' });
+ await waitForStatus(apiClient, { state: 'jammed' });
});
test('scanner powered off while returning', async () => {
- const { app, mockPlustek, interpreter } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek, interpreter } = await createApp();
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.unmarkedHmpb)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation = needsReviewInterpretation;
mockInterpretation(interpreter, interpretation);
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, { state: 'needs_review', interpretation });
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, { state: 'needs_review', interpretation });
- await post(app, '/precinct-scanner/scanner/return');
- await waitForStatus(app, {
+ await apiClient.returnBallot();
+ await waitForStatus(apiClient, {
state: 'returning',
interpretation,
});
mockPlustek.simulatePowerOff();
- await waitForStatus(app, { state: 'disconnected' });
+ await waitForStatus(apiClient, { state: 'disconnected' });
mockPlustek.simulatePowerOn('jam');
- await waitForStatus(app, { state: 'jammed' });
+ await waitForStatus(apiClient, { state: 'jammed' });
});
test('scanner powered off after returning', async () => {
- const { app, mockPlustek, interpreter } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek, interpreter } = await createApp();
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.unmarkedHmpb)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation = needsReviewInterpretation;
mockInterpretation(interpreter, interpretation);
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, { state: 'needs_review', interpretation });
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, { state: 'needs_review', interpretation });
- await post(app, '/precinct-scanner/scanner/return');
- await waitForStatus(app, {
+ await apiClient.returnBallot();
+ await waitForStatus(apiClient, {
state: 'returning',
interpretation,
});
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'returned',
interpretation,
});
mockPlustek.simulatePowerOff();
- await waitForStatus(app, { state: 'disconnected' });
+ await waitForStatus(apiClient, { state: 'disconnected' });
mockPlustek.simulatePowerOn('ready_to_scan');
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'rejected',
error: 'paper_in_front_after_reconnect',
});
});
test('insert second ballot while first ballot is scanning', async () => {
- const { app, mockPlustek } = await createApp(
+ const { apiClient, app, mockPlustek } = await createApp(
{},
{ passthroughDuration: 500 }
);
- await configureApp(app);
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await expectStatus(app, { state: 'both_sides_have_paper' });
+ await expectStatus(apiClient, { state: 'both_sides_have_paper' });
(await mockPlustek.simulateRemoveSheet()).unsafeUnwrap();
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'rejecting',
error: 'both_sides_have_paper',
});
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'rejected',
error: 'both_sides_have_paper',
});
(await mockPlustek.simulateRemoveSheet()).unsafeUnwrap();
- await waitForStatus(app, { state: 'no_paper' });
+ await waitForStatus(apiClient, { state: 'no_paper' });
});
test('insert second ballot before first ballot accept', async () => {
- const { app, mockPlustek, interpreter } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek, interpreter } = await createApp();
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation: Scan.SheetInterpretation = {
type: 'ValidSheet',
};
mockInterpretation(interpreter, interpretation);
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, { state: 'ready_to_accept', interpretation });
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, { state: 'ready_to_accept', interpretation });
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await post(app, '/precinct-scanner/scanner/accept');
+ await apiClient.acceptBallot();
- await waitForStatus(app, { state: 'both_sides_have_paper', interpretation });
+ await waitForStatus(apiClient, {
+ state: 'both_sides_have_paper',
+ interpretation,
+ });
(await mockPlustek.simulateRemoveSheet()).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_accept', interpretation });
- await post(app, '/precinct-scanner/scanner/accept');
- await expectStatus(app, { state: 'accepting', interpretation });
- await waitForStatus(app, {
+ await waitForStatus(apiClient, { state: 'ready_to_accept', interpretation });
+ await apiClient.acceptBallot();
+ await expectStatus(apiClient, { state: 'accepting', interpretation });
+ await waitForStatus(apiClient, {
state: 'accepted',
interpretation,
ballotsCounted: 1,
@@ -682,67 +683,70 @@ test('insert second ballot before first ballot accept', async () => {
});
test('insert second ballot while first ballot is accepting', async () => {
- const { app, mockPlustek, interpreter } = await createApp({
+ const { apiClient, app, mockPlustek, interpreter } = await createApp({
DELAY_ACCEPTED_READY_FOR_NEXT_BALLOT: 1000,
DELAY_ACCEPTED_RESET_TO_NO_PAPER: 2000,
});
- await configureApp(app);
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation: Scan.SheetInterpretation = {
type: 'ValidSheet',
};
mockInterpretation(interpreter, interpretation);
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, { state: 'ready_to_accept', interpretation });
- await post(app, '/precinct-scanner/scanner/accept');
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, { state: 'ready_to_accept', interpretation });
+ await apiClient.acceptBallot();
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'accepted',
interpretation,
ballotsCounted: 1,
});
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'ready_to_scan',
ballotsCounted: 1,
});
});
test('insert second ballot while first ballot needs review', async () => {
- const { app, mockPlustek, interpreter } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek, interpreter } = await createApp();
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.unmarkedHmpb)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation = needsReviewInterpretation;
mockInterpretation(interpreter, interpretation);
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, { state: 'needs_review', interpretation });
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, { state: 'needs_review', interpretation });
(
await mockPlustek.simulateLoadSheet(ballotImages.unmarkedHmpb)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'both_sides_have_paper', interpretation });
+ await waitForStatus(apiClient, {
+ state: 'both_sides_have_paper',
+ interpretation,
+ });
(await mockPlustek.simulateRemoveSheet()).unsafeUnwrap();
- await waitForStatus(app, { state: 'needs_review', interpretation });
+ await waitForStatus(apiClient, { state: 'needs_review', interpretation });
- await post(app, '/precinct-scanner/scanner/accept');
- await waitForStatus(app, {
+ await apiClient.acceptBallot();
+ await waitForStatus(apiClient, {
state: 'accepted',
interpretation,
ballotsCounted: 1,
@@ -750,16 +754,16 @@ test('insert second ballot while first ballot needs review', async () => {
});
test('insert second ballot while first ballot is rejecting', async () => {
- const { app, mockPlustek, interpreter } = await createApp(
+ const { apiClient, app, mockPlustek, interpreter } = await createApp(
{},
{ passthroughDuration: 500 }
);
- await configureApp(app);
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.wrongElection)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation: Scan.SheetInterpretation = {
type: 'InvalidSheet',
@@ -767,9 +771,9 @@ test('insert second ballot while first ballot is rejecting', async () => {
};
mockInterpretation(interpreter, interpretation);
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, {
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, {
state: 'rejecting',
interpretation,
});
@@ -777,163 +781,163 @@ test('insert second ballot while first ballot is rejecting', async () => {
(
await mockPlustek.simulateLoadSheet(ballotImages.wrongElection)
).unsafeUnwrap();
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'both_sides_have_paper',
interpretation,
});
(await mockPlustek.simulateRemoveSheet()).unsafeUnwrap();
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'rejecting',
interpretation,
});
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'rejected',
interpretation,
});
(await mockPlustek.simulateRemoveSheet()).unsafeUnwrap();
- await waitForStatus(app, { state: 'no_paper' });
+ await waitForStatus(apiClient, { state: 'no_paper' });
});
test('insert second ballot while first ballot is returning', async () => {
- const { app, mockPlustek, interpreter } = await createApp(
+ const { apiClient, app, mockPlustek, interpreter } = await createApp(
{},
{ passthroughDuration: 500 }
);
- await configureApp(app);
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.unmarkedHmpb)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation = needsReviewInterpretation;
mockInterpretation(interpreter, interpretation);
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, { state: 'needs_review', interpretation });
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, { state: 'needs_review', interpretation });
- await post(app, '/precinct-scanner/scanner/return');
+ await apiClient.returnBallot();
(
await mockPlustek.simulateLoadSheet(ballotImages.unmarkedHmpb)
).unsafeUnwrap();
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'both_sides_have_paper',
interpretation,
});
(await mockPlustek.simulateRemoveSheet()).unsafeUnwrap();
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'needs_review',
interpretation,
});
- await post(app, '/precinct-scanner/scanner/return');
- await waitForStatus(app, {
+ await apiClient.returnBallot();
+ await waitForStatus(apiClient, {
state: 'returned',
interpretation,
});
(await mockPlustek.simulateRemoveSheet()).unsafeUnwrap();
- await waitForStatus(app, { state: 'no_paper' });
+ await waitForStatus(apiClient, { state: 'no_paper' });
});
test('jam on scan', async () => {
- const { app, mockPlustek } = await createApp({
+ const { apiClient, app, mockPlustek } = await createApp({
DELAY_RECONNECT_ON_UNEXPECTED_ERROR: 500,
});
- await configureApp(app);
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
mockPlustek.simulateJamOnNextOperation();
- await post(app, '/precinct-scanner/scanner/scan');
- await waitForStatus(app, {
+ await apiClient.scanBallot();
+ await waitForStatus(apiClient, {
state: 'recovering_from_error',
error: 'plustek_error',
});
- await waitForStatus(app, { state: 'no_paper' });
+ await waitForStatus(apiClient, { state: 'no_paper' });
});
test('jam on accept', async () => {
- const { app, mockPlustek, interpreter } = await createApp({
+ const { apiClient, app, mockPlustek, interpreter } = await createApp({
DELAY_ACCEPTING_TIMEOUT: 500,
});
- await configureApp(app);
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation: Scan.SheetInterpretation = {
type: 'ValidSheet',
};
mockInterpretation(interpreter, interpretation);
- await post(app, '/precinct-scanner/scanner/scan');
- await waitForStatus(app, { state: 'scanning' });
- await waitForStatus(app, { state: 'ready_to_accept', interpretation });
+ await apiClient.scanBallot();
+ await waitForStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, { state: 'ready_to_accept', interpretation });
mockPlustek.simulateJamOnNextOperation();
- await post(app, '/precinct-scanner/scanner/accept');
- await waitForStatus(app, { state: 'accepting', interpretation });
+ await apiClient.acceptBallot();
+ await waitForStatus(apiClient, { state: 'accepting', interpretation });
// The paper can't get permanently jammed on accept - it just stays held in
// the back and we can reject at that point
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'rejecting',
interpretation,
error: 'paper_in_back_after_accept',
});
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'rejected',
error: 'paper_in_back_after_accept',
interpretation,
});
(await mockPlustek.simulateRemoveSheet()).unsafeUnwrap();
- await waitForStatus(app, { state: 'no_paper' });
+ await waitForStatus(apiClient, { state: 'no_paper' });
});
test('jam on return', async () => {
- const { app, mockPlustek, interpreter } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek, interpreter } = await createApp();
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.unmarkedHmpb)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation = needsReviewInterpretation;
mockInterpretation(interpreter, interpretation);
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, { state: 'needs_review', interpretation });
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, { state: 'needs_review', interpretation });
mockPlustek.simulateJamOnNextOperation();
- await post(app, '/precinct-scanner/scanner/return');
- await waitForStatus(app, {
+ await apiClient.returnBallot();
+ await waitForStatus(apiClient, {
state: 'jammed',
interpretation,
});
(await mockPlustek.simulateRemoveSheet()).unsafeUnwrap();
- await waitForStatus(app, { state: 'no_paper' });
+ await waitForStatus(apiClient, { state: 'no_paper' });
});
test('jam on reject', async () => {
- const { app, mockPlustek, interpreter } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek, interpreter } = await createApp();
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.wrongElection)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation: Scan.SheetInterpretation = {
type: 'InvalidSheet',
@@ -941,72 +945,63 @@ test('jam on reject', async () => {
};
mockInterpretation(interpreter, interpretation);
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
mockPlustek.simulateJamOnNextOperation();
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'jammed',
interpretation,
});
(await mockPlustek.simulateRemoveSheet()).unsafeUnwrap();
- await waitForStatus(app, { state: 'no_paper' });
+ await waitForStatus(apiClient, { state: 'no_paper' });
});
test('calibrate', async () => {
- const { app, mockPlustek } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek } = await createApp();
+ await configureApp(apiClient, app);
(await mockPlustek.simulateLoadSheet(ballotImages.blankSheet)).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
-
- // Supertest won't actually start the request until you call .then()
- const calibratePromise = post(
- app,
- '/precinct-scanner/scanner/calibrate'
- ).then();
- await waitForStatus(app, { state: 'calibrating' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
+
+ const calibratePromise = apiClient.calibrate();
+ await waitForStatus(apiClient, { state: 'calibrating' });
await calibratePromise;
- await expectStatus(app, { state: 'no_paper' });
+ await expectStatus(apiClient, { state: 'no_paper' });
});
test('jam on calibrate', async () => {
- const { app, mockPlustek } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek } = await createApp();
+ await configureApp(apiClient, app);
(await mockPlustek.simulateLoadSheet(ballotImages.blankSheet)).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
mockPlustek.simulateJamOnNextOperation();
- await request(app)
- .post('/precinct-scanner/scanner/calibrate')
- .accept('application/json')
- .expect(200, {
- status: 'error',
- errors: [{ type: 'error', message: 'plustek_error' }],
- });
- await expectStatus(app, { state: 'jammed' });
+ expect(await apiClient.calibrate()).toEqual(false);
+ await expectStatus(apiClient, { state: 'jammed' });
});
test('scan fails and retries', async () => {
- const { app, mockPlustek, logger, interpreter } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek, logger, interpreter } =
+ await createApp();
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const interpretation: Scan.SheetInterpretation = {
type: 'ValidSheet',
};
mockInterpretation(interpreter, interpretation);
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
mockPlustek.simulateScanError('error_feeding');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, { state: 'ready_to_accept', interpretation });
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, { state: 'ready_to_accept', interpretation });
// Make sure the underlying error got logged correctly
expect(logger.log).toHaveBeenCalledWith(
@@ -1023,39 +1018,42 @@ test('scan fails and retries', async () => {
});
test('scan fails repeatedly and eventually gives up', async () => {
- const { app, mockPlustek } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek } = await createApp();
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
const scanSpy = jest.spyOn(mockPlustek, 'scan');
- await post(app, '/precinct-scanner/scanner/scan');
+ await apiClient.scanBallot();
for (let i = 0; i < MAX_FAILED_SCAN_ATTEMPTS; i += 1) {
await waitForExpect(() => {
expect(scanSpy).toHaveBeenCalledTimes(i + 1);
});
- await expectStatus(app, { state: 'scanning' });
+ await expectStatus(apiClient, { state: 'scanning' });
mockPlustek.simulateScanError('error_feeding');
}
- await waitForStatus(app, { state: 'rejected', error: 'scanning_failed' });
+ await waitForStatus(apiClient, {
+ state: 'rejected',
+ error: 'scanning_failed',
+ });
});
test('scan fails due to plustek returning only one file instead of two', async () => {
- const { app, mockPlustek, logger } = await createApp();
- await configureApp(app);
+ const { apiClient, app, mockPlustek, logger } = await createApp();
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
mockPlustek.simulateScanError('only_one_file_returned');
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'unrecoverable_error',
error: 'plustek_error',
});
@@ -1075,24 +1073,24 @@ test('scan fails due to plustek returning only one file instead of two', async (
});
test('scanning time out', async () => {
- const { app, mockPlustek, logger } = await createApp({
+ const { apiClient, app, mockPlustek, logger } = await createApp({
DELAY_SCANNING_TIMEOUT: 50,
DELAY_RECONNECT_ON_UNEXPECTED_ERROR: 500,
});
- await configureApp(app);
+ await configureApp(apiClient, app);
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
- await waitForStatus(app, {
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
+ await waitForStatus(apiClient, {
state: 'recovering_from_error',
error: 'scanning_timed_out',
});
- await waitForStatus(app, { state: 'no_paper' });
+ await waitForStatus(apiClient, { state: 'no_paper' });
// Make sure the underlying error got logged correctly
expect(logger.log).toHaveBeenCalledWith(
@@ -1109,54 +1107,54 @@ test('scanning time out', async () => {
});
test('kills plustekctl if it freezes', async () => {
- const { app, mockPlustek } = await createApp({
+ const { apiClient, app, mockPlustek } = await createApp({
DELAY_SCANNING_TIMEOUT: 50,
DELAY_RECONNECT_ON_UNEXPECTED_ERROR: 500,
DELAY_KILL_AFTER_DISCONNECT_TIMEOUT: 500,
DELAY_PAPER_STATUS_POLLING_TIMEOUT: 1000,
});
- await configureApp(app);
+ await configureApp(apiClient, app);
- await waitForStatus(app, { state: 'no_paper' });
+ await waitForStatus(apiClient, { state: 'no_paper' });
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
mockPlustek.simulatePlustekctlFreeze();
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'recovering_from_error',
error: 'paper_status_timed_out',
});
- await waitForStatus(app, { state: 'no_paper' });
+ await waitForStatus(apiClient, { state: 'no_paper' });
});
test('stops completely if plustekctl freezes and cant be killed', async () => {
- const { app, mockPlustek } = await createApp({
+ const { apiClient, app, mockPlustek } = await createApp({
DELAY_SCANNING_TIMEOUT: 50,
DELAY_RECONNECT_ON_UNEXPECTED_ERROR: 500,
DELAY_KILL_AFTER_DISCONNECT_TIMEOUT: 500,
DELAY_PAPER_STATUS_POLLING_TIMEOUT: 1000,
});
- await configureApp(app);
+ await configureApp(apiClient, app);
- await waitForStatus(app, { state: 'no_paper' });
+ await waitForStatus(apiClient, { state: 'no_paper' });
(
await mockPlustek.simulateLoadSheet(ballotImages.completeBmd)
).unsafeUnwrap();
- await waitForStatus(app, { state: 'ready_to_scan' });
+ await waitForStatus(apiClient, { state: 'ready_to_scan' });
- await post(app, '/precinct-scanner/scanner/scan');
- await expectStatus(app, { state: 'scanning' });
+ await apiClient.scanBallot();
+ await expectStatus(apiClient, { state: 'scanning' });
mockPlustek.kill = () => err(new Error('could not kill'));
mockPlustek.simulatePlustekctlFreeze();
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'recovering_from_error',
error: 'paper_status_timed_out',
});
- await waitForStatus(app, {
+ await waitForStatus(apiClient, {
state: 'unrecoverable_error',
error: 'paper_status_timed_out',
});
diff --git a/apps/vx-scan/backend/src/index.ts b/apps/vx-scan/backend/src/index.ts
index de85ebec6..fb4bdbb5d 100644
--- a/apps/vx-scan/backend/src/index.ts
+++ b/apps/vx-scan/backend/src/index.ts
@@ -8,6 +8,8 @@ import { MOCK_SCANNER_HTTP, MOCK_SCANNER_PORT, NODE_ENV } from './globals';
import * as server from './server';
import { plustekMockServer } from './plustek_mock_server';
+export type { Api } from './app';
+
// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use
const dotEnvPath = '.env';
const dotenvFiles: string[] = [
diff --git a/apps/vx-scan/backend/src/store.ts b/apps/vx-scan/backend/src/store.ts
index d4417f9be..a27ed7b61 100644
--- a/apps/vx-scan/backend/src/store.ts
+++ b/apps/vx-scan/backend/src/store.ts
@@ -253,12 +253,12 @@ export class Store {
/**
* Sets the current test mode setting value.
*/
- setTestMode(testMode: boolean): void {
+ setTestMode(isTestMode: boolean): void {
if (!this.hasElection()) {
throw new Error('Cannot set test mode without an election.');
}
- this.client.run('update election set is_test_mode = ?', testMode ? 1 : 0);
+ this.client.run('update election set is_test_mode = ?', isTestMode ? 1 : 0);
}
/**
diff --git a/apps/vx-scan/backend/test/helpers/app_helpers.ts b/apps/vx-scan/backend/test/helpers/app_helpers.ts
index b434f3d76..67079f949 100644
--- a/apps/vx-scan/backend/test/helpers/app_helpers.ts
+++ b/apps/vx-scan/backend/test/helpers/app_helpers.ts
@@ -5,16 +5,11 @@ import {
readBallotPackageFromBuffer,
singlePrecinctSelectionFor,
} from '@votingworks/utils';
+import * as grout from '@votingworks/grout';
import { Application } from 'express';
import { Buffer } from 'buffer';
import request from 'supertest';
-import {
- CastVoteRecord,
- ok,
- PollsState,
- PrecinctId,
- Result,
-} from '@votingworks/types';
+import { CastVoteRecord, ok, PrecinctId, Result } from '@votingworks/types';
import { Scan } from '@votingworks/api';
import waitForExpect from 'wait-for-expect';
import { fakeLogger, Logger } from '@votingworks/logging';
@@ -26,7 +21,9 @@ import {
import { dirSync } from 'tmp';
import { electionFamousNames2021Fixtures } from '@votingworks/fixtures';
import { join } from 'path';
-import { buildApp } from '../../src/app';
+import { AddressInfo } from 'net';
+import fetch from 'node-fetch';
+import { buildApp, Api } from '../../src/app';
import {
createPrecinctScannerStateMachine,
Delays,
@@ -37,45 +34,11 @@ import {
} from '../../src/interpret';
import { createWorkspace, Workspace } from '../../src/util/workspace';
-export function get(app: Application, path: string): request.Test {
- return request(app).get(path).accept('application/json').expect(200);
-}
-
-export function patch(
- app: Application,
- path: string,
- body?: object | string
-): request.Test {
- return request(app)
- .patch(path)
- .accept('application/json')
- .set(
- 'Content-Type',
- typeof body === 'string' ? 'application/octet-stream' : 'application/json'
- )
- .send(body)
- .expect((res) => {
- // eslint-disable-next-line no-console
- if (res.status !== 200) console.error(res.body);
- })
- .expect(200, { status: 'ok' });
-}
-
-export function post(
- app: Application,
- path: string,
- body?: object
-): request.Test {
- return request(app)
- .post(path)
- .accept('application/json')
- .send(body)
- .expect((res) => {
- // eslint-disable-next-line no-console
- if (res.status !== 200) console.error(res.body);
- })
- .expect(200, { status: 'ok' });
-}
+// TODO(jonah) - Is there a way to ensure Grout always has access to node-fetch
+// in a node environment?
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore
+global.fetch = fetch;
export function postTemplate(
app: Application,
@@ -111,26 +74,6 @@ export function postTemplate(
.expect(200, { status: 'ok' });
}
-export function setAppPrecinct(
- app: Application,
- precinctId?: PrecinctId
-): request.Test {
- return patch(app, '/precinct-scanner/config/precinct', {
- precinctSelection: precinctId
- ? singlePrecinctSelectionFor(precinctId)
- : ALL_PRECINCTS_SELECTION,
- });
-}
-
-export function setPollsState(
- app: Application,
- pollsState: PollsState
-): request.Test {
- return patch(app, '/precinct-scanner/config/polls', {
- pollsState,
- });
-}
-
export async function postExportCvrs(
app: Application
): Promise {
@@ -147,31 +90,31 @@ export async function postExportCvrs(
}
export async function expectStatus(
- app: Application,
- status: {
+ apiClient: grout.Client,
+ expectedStatus: {
state: Scan.PrecinctScannerState;
} & Partial
): Promise {
- const response = await get(app, '/precinct-scanner/scanner/status');
- expect(response.body).toEqual({
+ const status = await apiClient.getScannerStatus();
+ expect(status).toEqual({
ballotsCounted: 0,
// TODO canUnconfigure should probably not be part of this endpoint - it's
// only needed on the admin screen
- canUnconfigure: !status?.ballotsCounted,
+ canUnconfigure: !expectedStatus?.ballotsCounted,
error: undefined,
interpretation: undefined,
- ...status,
+ ...expectedStatus,
});
}
export async function waitForStatus(
- app: Application,
+ apiClient: grout.Client,
status: {
state: Scan.PrecinctScannerState;
} & Partial
): Promise {
await waitForExpect(async () => {
- await expectStatus(app, status);
+ await expectStatus(apiClient, status);
}, 1_000);
}
@@ -179,6 +122,7 @@ export async function createApp(
delays: Partial = {},
mockPlustekOptions: Partial = {}
): Promise<{
+ apiClient: grout.Client;
app: Application;
mockPlustek: MockScannerClient;
workspace: Workspace;
@@ -213,10 +157,18 @@ export async function createApp(
},
});
const app = buildApp(precinctScannerMachine, interpreter, workspace, logger);
- await expectStatus(app, { state: 'connecting' });
+
+ const server = app.listen();
+ const { port } = server.address() as AddressInfo;
+ const baseUrl = `http://localhost:${port}/api`;
+
+ const apiClient = grout.createClient({ baseUrl });
+
+ await expectStatus(apiClient, { state: 'connecting' });
deferredConnect.resolve();
- await waitForStatus(app, { state: 'no_paper' });
+ await waitForStatus(apiClient, { state: 'no_paper' });
return {
+ apiClient,
app,
mockPlustek,
workspace,
@@ -255,6 +207,7 @@ export const ballotImages = {
} as const;
export async function configureApp(
+ apiClient: grout.Client,
app: Application,
{
addTemplates = false,
@@ -266,18 +219,20 @@ export async function configureApp(
const { ballots, electionDefinition } = await readBallotPackageFromBuffer(
electionFamousNames2021Fixtures.ballotPackage.asBuffer()
);
- await patch(
- app,
- '/precinct-scanner/config/election',
- electionDefinition.electionData
- );
+ await apiClient.setElection({
+ electionData: electionDefinition.electionData,
+ });
if (addTemplates) {
// It takes about a second per template, so we only do some
for (const ballot of ballots.slice(0, 2)) {
await postTemplate(app, '/precinct-scanner/config/addTemplates', ballot);
}
}
- await setAppPrecinct(app, precinctId);
- await patch(app, '/precinct-scanner/config/testMode', { testMode: false });
- await setPollsState(app, 'polls_open');
+ await apiClient.setPrecinctSelection({
+ precinctSelection: precinctId
+ ? singlePrecinctSelectionFor(precinctId)
+ : ALL_PRECINCTS_SELECTION,
+ });
+ await apiClient.setTestMode({ isTestMode: false });
+ await apiClient.setPollsState({ pollsState: 'polls_open' });
}
diff --git a/apps/vx-scan/backend/tsconfig.build.json b/apps/vx-scan/backend/tsconfig.build.json
index d68f1fdac..0544673bc 100644
--- a/apps/vx-scan/backend/tsconfig.build.json
+++ b/apps/vx-scan/backend/tsconfig.build.json
@@ -6,7 +6,8 @@
"noEmit": false,
"rootDir": "src",
"outDir": "build",
- "declaration": true
+ "declaration": true,
+ "declarationMap": true
},
"references": [
{ "path": "../../../libs/api/tsconfig.build.json" },
@@ -17,6 +18,7 @@
{ "path": "../../../libs/db/tsconfig.build.json" },
{ "path": "../../../libs/eslint-plugin-vx/tsconfig.build.json" },
{ "path": "../../../libs/fixtures/tsconfig.build.json" },
+ { "path": "../../../libs/grout/tsconfig.build.json" },
{ "path": "../../../libs/image-utils/tsconfig.build.json" },
{ "path": "../../../libs/logging/tsconfig.build.json" },
{ "path": "../../../libs/plustek-sdk/tsconfig.build.json" },
diff --git a/apps/vx-scan/backend/tsconfig.json b/apps/vx-scan/backend/tsconfig.json
index 36f8fb7af..d3ca8afd5 100644
--- a/apps/vx-scan/backend/tsconfig.json
+++ b/apps/vx-scan/backend/tsconfig.json
@@ -5,6 +5,7 @@
"compilerOptions": {
"allowJs": true,
"allowSyntheticDefaultImports": true,
+ "composite": true,
"esModuleInterop": true,
"lib": ["dom", "dom.iterable", "esnext"],
"module": "commonjs",
@@ -24,6 +25,7 @@
{ "path": "../../../libs/db/tsconfig.build.json" },
{ "path": "../../../libs/eslint-plugin-vx/tsconfig.build.json" },
{ "path": "../../../libs/fixtures/tsconfig.build.json" },
+ { "path": "../../../libs/grout/tsconfig.build.json" },
{ "path": "../../../libs/image-utils/tsconfig.build.json" },
{ "path": "../../../libs/logging/tsconfig.build.json" },
{ "path": "../../../libs/plustek-sdk/tsconfig.build.json" },
diff --git a/apps/vx-scan/frontend/.vscode/settings.json b/apps/vx-scan/frontend/.vscode/settings.json
index d6159a387..0da921a14 100644
--- a/apps/vx-scan/frontend/.vscode/settings.json
+++ b/apps/vx-scan/frontend/.vscode/settings.json
@@ -7,25 +7,9 @@
"typescript",
"typescriptreact"
],
- "editor.formatOnSave": true,
- "[javascript]": {
- "editor.formatOnSave": false
- },
- "[javascriptreact]": {
- "editor.formatOnSave": false
- },
- "[typescript]": {
- "editor.formatOnSave": false
- },
- "[typescriptreact]": {
- "editor.formatOnSave": false
- },
"css.validate": false,
"less.validate": false,
"scss.validate": false,
- "editor.codeActionsOnSave": {
- "source.fixAll.eslint": true
- },
"remote.SSH.defaultForwardedPorts": [
{
"localPort": 3000,
diff --git a/apps/vx-scan/frontend/jest.config.js b/apps/vx-scan/frontend/jest.config.js
index 58abf0beb..a05c6b217 100644
--- a/apps/vx-scan/frontend/jest.config.js
+++ b/apps/vx-scan/frontend/jest.config.js
@@ -13,8 +13,8 @@ module.exports = {
],
coverageThreshold: {
global: {
- statements: 95,
- branches: 86,
+ statements: 94,
+ branches: 85,
functions: 89,
lines: 95,
},
diff --git a/apps/vx-scan/frontend/package.json b/apps/vx-scan/frontend/package.json
index c6e4c24d9..166fcfcb4 100644
--- a/apps/vx-scan/frontend/package.json
+++ b/apps/vx-scan/frontend/package.json
@@ -68,6 +68,7 @@
"@votingworks/api": "workspace:*",
"@votingworks/ballot-interpreter-vx": "workspace:*",
"@votingworks/fixtures": "workspace:*",
+ "@votingworks/grout": "workspace:*",
"@votingworks/logging": "workspace:*",
"@votingworks/types": "workspace:*",
"@votingworks/ui": "workspace:*",
@@ -114,7 +115,9 @@
"@typescript-eslint/eslint-plugin": "^5.37.0",
"@typescript-eslint/parser": "^5.37.0",
"@vitejs/plugin-react": "^1.3.2",
+ "@votingworks/grout-test-utils": "workspace:*",
"@votingworks/test-utils": "workspace:*",
+ "@votingworks/vx-scan-backend": "workspace:*",
"eslint": "^8.23.1",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^8.5.0",
diff --git a/apps/vx-scan/frontend/prodserver/setupProxy.js b/apps/vx-scan/frontend/prodserver/setupProxy.js
index 3e915393f..a1aea05d0 100644
--- a/apps/vx-scan/frontend/prodserver/setupProxy.js
+++ b/apps/vx-scan/frontend/prodserver/setupProxy.js
@@ -12,6 +12,7 @@ const { createProxyMiddleware: proxy } = require('http-proxy-middleware');
* @param {import('connect').Server} app
*/
module.exports = function (app) {
+ app.use(proxy('/api', { target: 'http://localhost:3002/' }));
app.use(proxy('/precinct-scanner', { target: 'http://localhost:3002/' }));
app.use(proxy('/card', { target: 'http://localhost:3001/' }));
diff --git a/apps/vx-scan/frontend/src/api/api.ts b/apps/vx-scan/frontend/src/api/api.ts
new file mode 100644
index 000000000..2de397f20
--- /dev/null
+++ b/apps/vx-scan/frontend/src/api/api.ts
@@ -0,0 +1,16 @@
+/* eslint-disable vx/gts-no-import-export-type */
+import type { Api } from '@votingworks/vx-scan-backend';
+import React from 'react';
+import * as grout from '@votingworks/grout';
+
+export const ApiClientContext = React.createContext<
+ grout.Client | undefined
+>(undefined);
+
+export function useApiClient(): grout.Client {
+ const apiClient = React.useContext(ApiClientContext);
+ if (!apiClient) {
+ throw new Error('ApiClientContext.Provider not found');
+ }
+ return apiClient;
+}
diff --git a/apps/vx-scan/frontend/src/api/config.test.ts b/apps/vx-scan/frontend/src/api/config.test.ts
index 876b77416..6f9d45884 100644
--- a/apps/vx-scan/frontend/src/api/config.test.ts
+++ b/apps/vx-scan/frontend/src/api/config.test.ts
@@ -1,205 +1,20 @@
import { electionSampleDefinition as testElectionDefinition } from '@votingworks/fixtures';
-import { Scan } from '@votingworks/api';
import fetchMock from 'fetch-mock';
import { Buffer } from 'buffer';
-import { singlePrecinctSelectionFor, typedAs } from '@votingworks/utils';
-import { MarkThresholds } from '@votingworks/types';
import * as config from './config';
+import { createApiMock } from '../../test/helpers/mock_api_client';
-test('GET /config', async () => {
- fetchMock.getOnce(
- '/precinct-scanner/config',
- Scan.InitialPrecinctScannerConfig
- );
- expect(await config.get()).toEqual(Scan.InitialPrecinctScannerConfig);
-});
-
-test('PATCH /config/election', async () => {
- fetchMock.patchOnce(
- '/precinct-scanner/config/election',
- JSON.stringify({ status: 'ok' })
- );
- await config.setElection(testElectionDefinition.electionData);
-
- expect(
- fetchMock.calls('/precinct-scanner/config/election', { method: 'PATCH' })
- ).toHaveLength(1);
-});
-
-test('PATCH /config/election fails', async () => {
- const body: Scan.PatchElectionConfigResponse = {
- status: 'error',
- errors: [{ type: 'invalid-value', message: 'bad election!' }],
- };
- fetchMock.patchOnce('/precinct-scanner/config/election', {
- status: 400,
- body,
- });
- await expect(
- config.setElection(testElectionDefinition.electionData)
- ).rejects.toThrowError('bad election!');
-});
-
-test('DELETE /config/election to delete election', async () => {
- fetchMock.deleteOnce(
- '/precinct-scanner/config/election',
- JSON.stringify({ status: 'ok' })
- );
- await config.setElection(undefined);
-
- expect(
- fetchMock.calls('/precinct-scanner/config/election', { method: 'DELETE' })
- ).toHaveLength(1);
-});
-
-test('DELETE /config/election ?ignoreBackupRequirement query param', async () => {
- fetchMock.deleteOnce(
- '/precinct-scanner/config/election?ignoreBackupRequirement=true',
- JSON.stringify({ status: 'ok' })
- );
- await config.setElection(undefined, { ignoreBackupRequirement: true });
-
- expect(
- fetchMock.calls(
- '/precinct-scanner/config/election?ignoreBackupRequirement=true',
- { method: 'DELETE' }
- )
- ).toHaveLength(1);
-});
-
-test('DELETE /config/election to delete election with bad API response', async () => {
- fetchMock.deleteOnce(
- '/precinct-scanner/config/election',
- JSON.stringify({ status: 'not-ok' })
- );
- await expect(config.setElection(undefined)).rejects.toThrow(/DELETE/);
-});
-
-test('PATCH /config/testMode', async () => {
- fetchMock.patchOnce(
- {
- url: '/precinct-scanner/config/testMode',
- body: typedAs({ testMode: true }),
- },
- JSON.stringify({ status: 'ok' })
- );
- await config.setTestMode(true);
-
- expect(
- fetchMock.calls('/precinct-scanner/config/testMode', { method: 'PATCH' })
- ).toHaveLength(1);
-});
-
-test('setPrecinctSelection updates', async () => {
- const precinctSelection = singlePrecinctSelectionFor('23');
- fetchMock.patchOnce(
- {
- url: '/precinct-scanner/config/precinct',
- body: typedAs({
- precinctSelection,
- }),
- },
- JSON.stringify({ status: 'ok' })
- );
- await config.setPrecinctSelection(singlePrecinctSelectionFor('23'));
-
- expect(
- fetchMock.calls('/precinct-scanner/config/precinct', { method: 'PATCH' })
- ).toHaveLength(1);
-});
-
-test('setPrecinctSelection fails', async () => {
- fetchMock.patchOnce(
- '/precinct-scanner/config/precinct',
- JSON.stringify({ status: 'error' })
- );
- await expect(
- config.setPrecinctSelection(singlePrecinctSelectionFor('23'))
- ).rejects.toThrowErrorMatchingInlineSnapshot(
- '"PATCH /precinct-scanner/config/precinct failed: undefined"'
- );
-});
-
-test('PATCH setMarkThresholds', async () => {
- const markThresholdOverrides: MarkThresholds = {
- definite: 0.25,
- marginal: 0.5,
- };
- fetchMock.patchOnce(
- {
- url: '/precinct-scanner/config/markThresholdOverrides',
- body: typedAs({
- markThresholdOverrides,
- }),
- },
- {
- body: { status: 'ok' },
- }
- );
- await config.setMarkThresholdOverrides(markThresholdOverrides);
-
- expect(
- fetchMock.calls('/precinct-scanner/config/markThresholdOverrides', {
- method: 'PATCH',
- })
- ).toHaveLength(1);
-});
-
-test('setMarkThresholds deletes', async () => {
- fetchMock.deleteOnce('/precinct-scanner/config/markThresholdOverrides', {
- body: { status: 'ok' },
- });
- await config.setMarkThresholdOverrides(undefined);
-
- expect(
- fetchMock.calls('/precinct-scanner/config/markThresholdOverrides', {
- method: 'DELETE',
- })
- ).toHaveLength(1);
-});
-
-test('PATCH isSoundMuted', async () => {
- fetchMock.patchOnce(
- {
- url: '/precinct-scanner/config/isSoundMuted',
- body: typedAs({
- isSoundMuted: true,
- }),
- },
- {
- body: { status: 'ok' },
- }
- );
- await config.setIsSoundMuted(true);
-
- expect(
- fetchMock.calls('/precinct-scanner/config/isSoundMuted', {
- method: 'PATCH',
- })
- ).toHaveLength(1);
-});
-
-test('PATCH ballotBagReplaced', async () => {
- fetchMock.patchOnce('/precinct-scanner/config/ballotBagReplaced', {
- body: { status: 'ok' },
- });
- await config.setBallotBagReplaced();
-
- expect(
- fetchMock.calls('/precinct-scanner/config/ballotBagReplaced', {
- method: 'PATCH',
- })
- ).toHaveLength(1);
-});
+const apiMock = createApiMock();
test('addTemplates configures the server with the contained election', async () => {
- fetchMock.patchOnce('/precinct-scanner/config/election', {
- body: { status: 'ok' },
- });
+ apiMock.expectSetElection(testElectionDefinition);
await new Promise((resolve, reject) => {
config
- .addTemplates({ electionDefinition: testElectionDefinition, ballots: [] })
+ .addTemplates(apiMock.mockApiClient, {
+ electionDefinition: testElectionDefinition,
+ ballots: [],
+ })
.on('error', (error) => {
reject(error);
})
@@ -210,9 +25,7 @@ test('addTemplates configures the server with the contained election', async ()
});
test('addTemplates emits an event each time a ballot begins uploading', async () => {
- fetchMock.patchOnce('/precinct-scanner/config/election', {
- body: { status: 'ok' },
- });
+ apiMock.expectSetElection(testElectionDefinition);
fetchMock.post('/precinct-scanner/config/addTemplates', {
body: { status: 'ok' },
});
@@ -221,7 +34,7 @@ test('addTemplates emits an event each time a ballot begins uploading', async ()
await new Promise((resolve, reject) => {
config
- .addTemplates({
+ .addTemplates(apiMock.mockApiClient, {
electionDefinition: testElectionDefinition,
ballots: [
{
@@ -269,19 +82,16 @@ test('addTemplates emits an event each time a ballot begins uploading', async ()
});
test('addTemplates emits error on API failure', async () => {
- const body: Scan.PatchElectionConfigResponse = {
- status: 'error',
- errors: [{ type: 'invalid-value', message: 'bad election!' }],
- };
- fetchMock.patchOnce('/precinct-scanner/config/election', {
- status: 400,
- body,
- });
+ apiMock.mockApiClient.setElection
+ .expectCallWith({
+ electionData: testElectionDefinition.electionData,
+ })
+ .throws(new Error('bad election!'));
await expect(
new Promise((resolve, reject) => {
config
- .addTemplates({
+ .addTemplates(apiMock.mockApiClient, {
electionDefinition: testElectionDefinition,
ballots: [],
})
diff --git a/apps/vx-scan/frontend/src/api/config.ts b/apps/vx-scan/frontend/src/api/config.ts
index 3e02b2ea3..d530a4d42 100644
--- a/apps/vx-scan/frontend/src/api/config.ts
+++ b/apps/vx-scan/frontend/src/api/config.ts
@@ -1,137 +1,7 @@
-import {
- ElectionDefinition,
- MarkThresholds,
- safeParseJson,
- PrecinctSelection,
- PollsState,
-} from '@votingworks/types';
-import { ErrorsResponse, OkResponse, Scan } from '@votingworks/api';
+import { ElectionDefinition } from '@votingworks/types';
import { BallotPackage, BallotPackageEntry, assert } from '@votingworks/utils';
import { EventEmitter } from 'events';
-
-async function patch(
- url: string,
- value: Body
-): Promise {
- const isJson =
- typeof value !== 'string' &&
- !(value instanceof ArrayBuffer) &&
- !(value instanceof Uint8Array);
- const response = await fetch(url, {
- method: 'PATCH',
- body: isJson ? JSON.stringify(value) : (value as BodyInit),
- headers: {
- 'Content-Type': /* istanbul ignore next */ isJson
- ? 'application/json'
- : 'application/octet-stream',
- },
- });
- const body: OkResponse | ErrorsResponse = await response.json();
-
- if (body.status !== 'ok') {
- throw new Error(`PATCH ${url} failed: ${JSON.stringify(body.errors)}`);
- }
-}
-
-async function del(url: string): Promise {
- const response = await fetch(url, {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- });
- const body: OkResponse | ErrorsResponse = await response.json();
-
- if (body.status !== 'ok') {
- throw new Error(`DELETE ${url} failed: ${JSON.stringify(body.errors)}`);
- }
-}
-
-export async function get(): Promise {
- return safeParseJson(
- await (
- await fetch('/precinct-scanner/config', {
- headers: { Accept: 'application/json' },
- })
- ).text(),
- Scan.GetPrecinctScannerConfigResponseSchema
- ).unsafeUnwrap();
-}
-
-export async function setElection(
- electionData?: string,
- { ignoreBackupRequirement }: { ignoreBackupRequirement?: boolean } = {}
-): Promise {
- if (typeof electionData === 'undefined') {
- let deletionUrl = '/precinct-scanner/config/election';
- if (ignoreBackupRequirement) {
- deletionUrl += '?ignoreBackupRequirement=true';
- }
- await del(deletionUrl);
- } else {
- // TODO(528) add proper typing here
- await patch('/precinct-scanner/config/election', electionData);
- }
-}
-
-export async function setPrecinctSelection(
- precinctSelection: PrecinctSelection
-): Promise {
- await patch(
- '/precinct-scanner/config/precinct',
- {
- precinctSelection,
- }
- );
-}
-
-export async function setMarkThresholdOverrides(
- markThresholdOverrides?: MarkThresholds
-): Promise {
- if (typeof markThresholdOverrides === 'undefined') {
- await del('/precinct-scanner/config/markThresholdOverrides');
- } else {
- await patch(
- '/precinct-scanner/config/markThresholdOverrides',
- { markThresholdOverrides }
- );
- }
-}
-
-export async function setIsSoundMuted(isSoundMuted: boolean): Promise {
- await patch(
- '/precinct-scanner/config/isSoundMuted',
- { isSoundMuted }
- );
-}
-
-export async function setTestMode(testMode: boolean): Promise {
- await patch(
- '/precinct-scanner/config/testMode',
- {
- testMode,
- }
- );
-}
-
-export async function setPollsState(pollsState: PollsState): Promise {
- await patch('/precinct-scanner/config/polls', {
- pollsState,
- });
-}
-
-export async function setBallotBagReplaced(): Promise {
- const response = await fetch('/precinct-scanner/config/ballotBagReplaced', {
- method: 'PATCH',
- });
- const body: OkResponse | ErrorsResponse = await response.json();
-
- if (body.status !== 'ok') {
- throw new Error(
- `PATCH /precinct-scanner/config/ballotBagReplaced failed: ${JSON.stringify(
- body.errors
- )}`
- );
- }
-}
+import { useApiClient } from './api';
export interface AddTemplatesEvents extends EventEmitter {
on(
@@ -174,13 +44,18 @@ export interface AddTemplatesEvents extends EventEmitter {
emit(event: 'error', error: Error): boolean;
}
-export function addTemplates(pkg: BallotPackage): AddTemplatesEvents {
+export function addTemplates(
+ apiClient: ReturnType,
+ pkg: BallotPackage
+): AddTemplatesEvents {
const result: AddTemplatesEvents = new EventEmitter();
setImmediate(async () => {
try {
result.emit('configuring', pkg, pkg.electionDefinition);
- await setElection(pkg.electionDefinition.electionData);
+ await apiClient.setElection({
+ electionData: pkg.electionDefinition.electionData,
+ });
for (const ballot of pkg.ballots) {
result.emit('uploading', pkg, ballot);
diff --git a/apps/vx-scan/frontend/src/api/scan.test.ts b/apps/vx-scan/frontend/src/api/scan.test.ts
index f2c1f8e73..e7ea81a82 100644
--- a/apps/vx-scan/frontend/src/api/scan.test.ts
+++ b/apps/vx-scan/frontend/src/api/scan.test.ts
@@ -3,14 +3,6 @@ import { CastVoteRecord } from '@votingworks/types';
import fetchMock from 'fetch-mock';
import * as scan from './scan';
-test('calibrate success', async () => {
- fetchMock.postOnce('/precinct-scanner/scanner/calibrate', {
- body: { status: 'ok' },
- });
- await scan.calibrate();
- expect(fetchMock.done()).toBe(true);
-});
-
test('getExportWithoutImages returns CVRs on success', async () => {
const fileContent = electionWithMsEitherNeitherFixtures.cvrData;
fetchMock.postOnce('/precinct-scanner/export', fileContent);
diff --git a/apps/vx-scan/frontend/src/api/scan.ts b/apps/vx-scan/frontend/src/api/scan.ts
index 76f400d76..0525ba9ef 100644
--- a/apps/vx-scan/frontend/src/api/scan.ts
+++ b/apps/vx-scan/frontend/src/api/scan.ts
@@ -1,37 +1,7 @@
-import { unsafeParse } from '@votingworks/types';
-import { Scan } from '@votingworks/api';
-import { fetchJson } from '@votingworks/utils';
import { rootDebug } from '../utils/debug';
const debug = rootDebug.extend('api:scan');
-export async function getStatus(): Promise {
- return unsafeParse(
- Scan.GetPrecinctScannerStatusResponseSchema,
- await fetchJson('/precinct-scanner/scanner/status')
- );
-}
-
-export async function scanBallot(): Promise {
- await fetchJson('/precinct-scanner/scanner/scan', { method: 'POST' });
-}
-
-export async function acceptBallot(): Promise {
- await fetchJson('/precinct-scanner/scanner/accept', { method: 'POST' });
-}
-
-export async function returnBallot(): Promise {
- await fetchJson('/precinct-scanner/scanner/return', { method: 'POST' });
-}
-
-export async function calibrate(): Promise {
- const result = unsafeParse(
- Scan.CalibrateResponseSchema,
- await fetchJson('/precinct-scanner/scanner/calibrate', { method: 'POST' })
- );
- return result.status === 'ok';
-}
-
// Returns CVR file which does not include any write-in images
export async function getExportWithoutImages(): Promise {
const response = await fetch('/precinct-scanner/export', {
diff --git a/apps/vx-scan/frontend/src/app.test.tsx b/apps/vx-scan/frontend/src/app.test.tsx
index 91b9bf0f9..4fb126d07 100644
--- a/apps/vx-scan/frontend/src/app.test.tsx
+++ b/apps/vx-scan/frontend/src/app.test.tsx
@@ -1,3 +1,4 @@
+import React from 'react';
import fetchMock from 'fetch-mock';
import { promises as fs } from 'fs';
import { Scan } from '@votingworks/api';
@@ -6,9 +7,13 @@ import {
ReportSourceMachineType,
readBallotPackageFromFilePointer,
singlePrecinctSelectionFor,
+ MemoryCard,
+ MemoryHardware,
+ MemoryStorage,
+ BallotPackage,
} from '@votingworks/utils';
-import { LogEventId } from '@votingworks/logging';
-import { waitFor, screen, within } from '@testing-library/react';
+import { fakeLogger, LogEventId } from '@votingworks/logging';
+import { waitFor, screen, within, render } from '@testing-library/react';
import {
fakeKiosk,
fakeUsbDrive,
@@ -41,8 +46,10 @@ import {
import { REPRINT_REPORT_TIMEOUT_SECONDS } from './screens/poll_worker_screen';
import { SELECT_PRECINCT_TEXT } from './screens/election_manager_screen';
import { fakeFileWriter } from '../test/helpers/fake_file_writer';
-import { buildApp } from '../test/helpers/build_app';
-import { mockConfig } from '../test/helpers/mock_config';
+import { createApiMock, statusNoPaper } from '../test/helpers/mock_api_client';
+import { App, AppProps } from './app';
+
+const apiMock = createApiMock();
jest.setTimeout(20000);
@@ -65,13 +72,6 @@ const getMachineConfigBody: MachineConfigResponse = {
codeVersion: '3.14',
};
-const deleteElectionConfigResponseBody: Scan.DeleteElectionConfigResponse = {
- status: 'ok',
-};
-
-const statusNoPaper = scannerStatus({ state: 'no_paper' });
-const statusReadyToScan = scannerStatus({ state: 'ready_to_scan' });
-
let kiosk = fakeKiosk();
const pollWorkerCard = makePollWorkerCard(
@@ -83,6 +83,28 @@ const electionManagerCard = makeElectionManagerCard(
'123456'
);
+function renderApp(props: Partial = {}) {
+ const card = new MemoryCard();
+ const hardware = MemoryHardware.build({
+ connectPrinter: false,
+ connectCardReader: true,
+ connectPrecinctScanner: true,
+ });
+ const logger = fakeLogger();
+ const storage = new MemoryStorage();
+ render(
+
+ );
+ return { card, hardware, logger, storage };
+}
+
beforeEach(() => {
jest.useFakeTimers();
@@ -95,32 +117,36 @@ beforeEach(() => {
fetchMock.reset();
fetchMock.get('/machine-config', { body: getMachineConfigBody });
+
+ apiMock.mockApiClient.reset();
+});
+
+afterEach(() => {
+ apiMock.mockApiClient.assertComplete();
});
test('shows setup card reader screen when there is no card reader', async () => {
- mockConfig();
- const { hardware, renderApp } = buildApp();
- hardware.setCardReaderConnected(false);
- fetchMock.get('/precinct-scanner/scanner/status', { body: statusNoPaper });
- renderApp();
+ apiMock.expectGetConfig();
+ const hardware = MemoryHardware.build({ connectCardReader: false });
+ renderApp({ hardware });
await screen.findByText('Card Reader Not Detected');
});
test('shows insert USB Drive screen when there is no card reader', async () => {
- mockConfig();
+ apiMock.expectGetConfig();
kiosk.getUsbDrives.mockResolvedValue([]);
- fetchMock.get('/precinct-scanner/scanner/status', { body: statusNoPaper });
- buildApp().renderApp();
+ apiMock.expectGetScannerStatus(statusNoPaper);
+ renderApp();
await screen.findByText('No USB Drive Detected');
});
test('app can load and configure from a usb stick', async () => {
- const { mockElectionDefinitionChange } = mockConfig({
+ apiMock.expectGetConfig({
electionDefinition: undefined,
});
kiosk.getUsbDrives.mockResolvedValue([]);
- fetchMock.get('/precinct-scanner/scanner/status', { body: statusNoPaper });
- buildApp().renderApp();
+ apiMock.expectGetScannerStatus(statusNoPaper, 7);
+ renderApp();
await screen.findByText('Loading Configuration…');
await advanceTimersAndPromises(1);
await screen.findByText('VxScan is Not Configured');
@@ -145,7 +171,6 @@ test('app can load and configure from a usb stick', async () => {
await screen.findByText(
'Error in configuration: No ballot package found on the inserted USB drive.'
);
-
// Remove the USB
kiosk.getUsbDrives.mockResolvedValue([]);
await advanceTimersAndPromises(2);
@@ -177,9 +202,11 @@ test('app can load and configure from a usb stick', async () => {
]);
const fileContent = await fs.readFile(pathToFile);
kiosk.readFile.mockResolvedValue(fileContent as unknown as string);
- const ballotPackage = await readBallotPackageFromFile(
+ const ballotPackage = (await readBallotPackageFromFile(
new File([fileContent], 'ballot-package-new.zip')
- );
+ )) as BallotPackage;
+ const { electionDefinition } = ballotPackage;
+ expect(electionDefinition).toBeDefined();
/* This function can take too long when the test is running for the results to be seen in time for the
* test to pass consistently. By running it above and mocking out the result we guarantee the test will
* pass consistently.
@@ -190,7 +217,8 @@ test('app can load and configure from a usb stick', async () => {
body: '{"status": "ok"}',
status: 200,
});
- mockElectionDefinitionChange(electionSampleDefinition);
+ apiMock.expectSetElection(electionDefinition);
+ apiMock.expectGetConfig({ electionDefinition });
// Reinsert USB now that fake zip file on it is setup
kiosk.getUsbDrives.mockResolvedValue([fakeUsbDrive()]);
@@ -200,9 +228,6 @@ test('app can load and configure from a usb stick', async () => {
expect(kiosk.getFileSystemEntries).toHaveBeenCalledWith(
'fake mount point/ballot-packages'
);
- expect(
- fetchMock.calls('/precinct-scanner/config/election', { method: 'PATCH' })
- ).toHaveLength(1);
expect(fetchMock.calls('/precinct-scanner/config/addTemplates')).toHaveLength(
16
);
@@ -215,10 +240,11 @@ test('app can load and configure from a usb stick', async () => {
});
test('election manager must set precinct', async () => {
- const { mockPrecinctChange } = mockConfig({ precinctSelection: undefined });
- fetchMock.get('/precinct-scanner/scanner/status', { body: statusNoPaper });
- const { card, renderApp } = buildApp();
- renderApp();
+ apiMock.expectGetConfig({
+ precinctSelection: undefined,
+ });
+ apiMock.expectGetScannerStatus(statusNoPaper, 3);
+ const { card } = renderApp();
await screen.findByText('No Precinct Selected');
// Poll Worker card does nothing
@@ -231,7 +257,10 @@ test('election manager must set precinct', async () => {
card.insertCard(electionManagerCard, electionSampleDefinition.electionData);
await authenticateElectionManagerCard();
screen.getByText(SELECT_PRECINCT_TEXT);
- mockPrecinctChange(singlePrecinctSelectionFor('23'));
+ apiMock.expectSetPrecinct(singlePrecinctSelectionFor('23'));
+ apiMock.expectGetConfig({
+ precinctSelection: singlePrecinctSelectionFor('23'),
+ });
userEvent.selectOptions(await screen.findByTestId('selectPrecinct'), '23');
card.removeCard();
// Confirm precinct is set and correct
@@ -245,42 +274,32 @@ test('election manager must set precinct', async () => {
});
test('election manager and poll worker configuration', async () => {
- const { mockPrecinctChange, mockPollsChange, mockTestModeChange } =
- mockConfig();
- const { card, renderApp, logger } = buildApp();
- fetchMock.get('/precinct-scanner/scanner/status', { body: statusNoPaper });
- renderApp();
+ const electionDefinition = electionSampleDefinition;
+ let config: Partial = { electionDefinition };
+ apiMock.expectGetConfig(config);
+ const { card, logger } = renderApp();
+ apiMock.expectGetScannerStatus(statusNoPaper);
await screen.findByText('Polls Closed');
// Calibrate scanner as Election Manager
- fetchMock.post('/precinct-scanner/scanner/calibrate', {
- body: { status: 'ok' },
- });
- card.insertCard(electionManagerCard, electionSampleDefinition.electionData);
+ card.insertCard(electionManagerCard, electionDefinition.electionData);
await authenticateElectionManagerCard();
userEvent.click(await screen.findByText('Calibrate Scanner'));
await screen.findByText('Waiting for Paper');
userEvent.click(await screen.findByText('Cancel'));
expect(screen.queryByText('Waiting for Paper')).toBeNull();
userEvent.click(await screen.findByText('Calibrate Scanner'));
- fetchMock
- .getOnce(
- '/precinct-scanner/scanner/status',
- { body: statusReadyToScan },
- { overwriteRoutes: true }
- )
- .get('/precinct-scanner/scanner/status', { body: statusNoPaper });
- await advanceTimersAndPromises();
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'ready_to_scan' }));
+ apiMock.mockApiClient.calibrate.expectCallWith().resolves(true);
userEvent.click(await screen.findByText('Calibrate'));
- expect(fetchMock.calls('/precinct-scanner/scanner/calibrate')).toHaveLength(
- 1
- );
- await advanceTimersAndPromises();
await screen.findByText('Calibration succeeded!');
userEvent.click(screen.getByRole('button', { name: 'Close' }));
// Change mode as Election Manager
- mockTestModeChange(false);
+ apiMock.expectGetScannerStatus(statusNoPaper);
+ apiMock.expectSetTestMode(false);
+ config = { ...config, isTestMode: true };
+ apiMock.expectGetConfig(config);
userEvent.click(await screen.findByText('Live Election Mode'));
await screen.findByText('Loading');
await advanceTimersAndPromises(1);
@@ -291,7 +310,10 @@ test('election manager and poll worker configuration', async () => {
);
// Change precinct as Election Manager
- mockPrecinctChange(singlePrecinctSelectionFor('23'));
+ apiMock.expectGetScannerStatus(statusNoPaper);
+ apiMock.expectSetPrecinct(singlePrecinctSelectionFor('23'));
+ config = { ...config, precinctSelection: singlePrecinctSelectionFor('23') };
+ apiMock.expectGetConfig(config);
userEvent.selectOptions(await screen.findByTestId('selectPrecinct'), '23');
await waitFor(() => {
expect(logger.log).toHaveBeenCalledWith(
@@ -307,6 +329,7 @@ test('election manager and poll worker configuration', async () => {
await advanceTimersAndPromises(1);
// Open the polls
+ apiMock.expectGetScannerStatus(statusNoPaper);
fetchMock.post('/precinct-scanner/export', {});
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to open the polls?');
@@ -316,7 +339,11 @@ test('election manager and poll worker configuration', async () => {
'poll_worker',
expect.objectContaining({ disposition: 'success' })
);
- mockPollsChange('polls_open');
+
+ apiMock.expectGetScannerStatus(statusNoPaper, 2);
+ apiMock.expectSetPollsState('polls_open');
+ config = { ...config, pollsState: 'polls_open' };
+ apiMock.expectGetConfig(config);
userEvent.click(await screen.findByText('Yes, Open the Polls'));
await advanceTimersAndPromises(1);
expect(fetchMock.calls('/precinct-scanner/export')).toHaveLength(1);
@@ -326,13 +353,20 @@ test('election manager and poll worker configuration', async () => {
card.removeCard();
await advanceTimersAndPromises(1);
expect(fetchMock.calls('/precinct-scanner/export')).toHaveLength(1);
+
// Change precinct as Election Manager with polls open
- card.insertCard(electionManagerCard, electionSampleDefinition.electionData);
+ apiMock.expectGetScannerStatus(statusNoPaper);
+ apiMock.expectSetPrecinct(singlePrecinctSelectionFor('20'));
+ config = {
+ ...config,
+ precinctSelection: singlePrecinctSelectionFor('20'),
+ pollsState: 'polls_closed_initial',
+ };
+ apiMock.expectGetConfig(config);
+ card.insertCard(electionManagerCard, electionDefinition.electionData);
await authenticateElectionManagerCard();
userEvent.click(screen.getByText('Change Precinct'));
screen.getByText(/WARNING/);
- mockPrecinctChange(singlePrecinctSelectionFor('20'));
- mockPollsChange('polls_closed_initial');
userEvent.selectOptions(await screen.findByTestId('selectPrecinct'), '20');
userEvent.click(screen.getByText('Confirm'));
await waitFor(() => {
@@ -353,6 +387,7 @@ test('election manager and poll worker configuration', async () => {
await screen.findByText('South Springfield,');
// Open the polls again
+ apiMock.expectGetScannerStatus(statusNoPaper, 2);
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to open the polls?');
expect(fetchMock.calls('/precinct-scanner/export')).toHaveLength(2);
@@ -361,7 +396,12 @@ test('election manager and poll worker configuration', async () => {
'poll_worker',
expect.objectContaining({ disposition: 'success' })
);
- mockPollsChange('polls_open');
+ apiMock.expectSetPollsState('polls_open');
+ config = {
+ ...config,
+ pollsState: 'polls_open',
+ };
+ apiMock.expectGetConfig(config);
userEvent.click(await screen.findByText('Yes, Open the Polls'));
await screen.findByText(
'Insert poll worker card into VxMark to print the report.'
@@ -370,17 +410,15 @@ test('election manager and poll worker configuration', async () => {
await advanceTimersAndPromises(1);
// Remove card and insert election manager card to unconfigure
- fetchMock
- .get(
- '/precinct-scanner/scanner/status',
- { body: { ...statusNoPaper, canUnconfigure: true, ballotsCounted: 1 } },
- { overwriteRoutes: true }
- )
- .delete('./precinct-scanner/config/election', {
- body: '{"status": "ok"}',
- status: 200,
- });
- card.insertCard(electionManagerCard, electionSampleDefinition.electionData);
+ apiMock.expectGetScannerStatus(
+ {
+ ...statusNoPaper,
+ canUnconfigure: true,
+ ballotsCounted: 1,
+ },
+ 3
+ );
+ card.insertCard(electionManagerCard, electionDefinition.electionData);
await authenticateElectionManagerCard();
// Confirm we can't unconfigure just by changing precinct
expect(await screen.findByTestId('selectPrecinct')).toBeDisabled();
@@ -396,29 +434,26 @@ test('election manager and poll worker configuration', async () => {
'Do you want to remove all election information and data from this machine?'
)
).toBeNull();
+
+ // Actually unconfigure
+ apiMock.mockApiClient.unconfigureElection.expectCallWith({}).resolves();
+ apiMock.expectGetConfig({ electionDefinition: undefined });
userEvent.click(
await screen.findByText('Delete All Election Data from VxScan')
);
userEvent.click(await screen.findByText('Yes, Delete All'));
await screen.findByText('Loading');
- await waitFor(() =>
- expect(
- fetchMock.calls('./precinct-scanner/config/election', {
- method: 'DELETE',
- })
- )
- );
+ await screen.findByText('VxScan is Not Configured');
expect(kiosk.unmountUsbDrive).toHaveBeenCalledTimes(1);
});
test('voter can cast a ballot that scans successfully ', async () => {
- const { mockPollsChange } = mockConfig({ pollsState: 'polls_open' });
- const { card, renderApp } = buildApp();
- const writeLongObjectMock = jest.spyOn(card, 'writeLongObject');
- fetchMock.get('/precinct-scanner/scanner/status', {
- body: statusNoPaper,
+ apiMock.expectGetConfig({
+ pollsState: 'polls_open',
});
- renderApp();
+ const { card } = renderApp();
+ const writeLongObjectMock = jest.spyOn(card, 'writeLongObject');
+ apiMock.expectGetScannerStatus(statusNoPaper);
await screen.findByText('Insert Your Ballot Below');
screen.getByText('Scan one ballot sheet at a time.');
screen.getByText('General Election');
@@ -426,28 +461,18 @@ test('voter can cast a ballot that scans successfully ', async () => {
screen.getByText(/State of Hamilton/);
screen.getByText('Election ID');
screen.getByText('748dc61ad3');
- fetchMock
- .getOnce(
- '/precinct-scanner/scanner/status',
- {
- body: scannerStatus({ state: 'ready_to_scan' }),
- },
- { overwriteRoutes: true }
- )
- .post('/precinct-scanner/scanner/scan', { body: { status: 'ok' } })
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'scanning' }),
- })
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'ready_to_accept' }),
- })
- .post('/precinct-scanner/scanner/accept', { body: { status: 'ok' } })
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'accepted' }),
- })
- .get('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'no_paper', ballotsCounted: 1 }),
- });
+
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'ready_to_scan' }));
+ apiMock.mockApiClient.scanBallot.expectCallWith().resolves();
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'scanning' }));
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'ready_to_accept' }));
+ apiMock.mockApiClient.acceptBallot.expectCallWith().resolves();
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'accepted' }));
+ const statusBallotCounted = scannerStatus({
+ state: 'no_paper',
+ ballotsCounted: 1,
+ });
+ apiMock.expectGetScannerStatus(statusBallotCounted);
// trigger scan
await advanceTimersAndPromises(POLLING_INTERVAL_FOR_SCANNER_STATUS_MS / 1000);
@@ -459,8 +484,9 @@ test('voter can cast a ballot that scans successfully ', async () => {
await advanceTimersAndPromises(POLLING_INTERVAL_FOR_SCANNER_STATUS_MS / 1000);
await screen.findByText('Scan one ballot sheet at a time.');
expect((await screen.findByTestId('ballot-count')).textContent).toBe('1');
- expect(fetchMock.done()).toBe(true);
+
// Insert a pollworker card
+ apiMock.expectGetScannerStatus(statusBallotCounted, 7);
fetchMock.post('/precinct-scanner/export', {
_precinctId: '23',
_ballotStyleId: '12',
@@ -470,12 +496,15 @@ test('voter can cast a ballot that scans successfully ', async () => {
'county-registrar-of-wills': ['write-in'],
'judicial-robert-demergue': ['yes'],
});
-
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to close the polls?');
// Close Polls
- mockPollsChange('polls_closed_final');
+ apiMock.expectSetPollsState('polls_closed_final');
+ apiMock.expectGetConfig({
+ pollsState: 'polls_closed_final',
+ });
+
userEvent.click(await screen.findByText('Yes, Close the Polls'));
await screen.findByText('Closing Polls…');
await screen.findByText('Polls are closed.');
@@ -556,37 +585,25 @@ test('voter can cast a ballot that scans successfully ', async () => {
userEvent.click(await screen.findByText('Eject USB'));
expect(screen.queryByText('Eject USB')).toBeNull();
await advanceTimersAndPromises(1);
+ expect(fetchMock.done()).toBe(true);
});
test('voter can cast a ballot that needs review and adjudicate as desired', async () => {
- mockConfig({ pollsState: 'polls_open' });
- fetchMock.getOnce('/precinct-scanner/scanner/status', {
- body: statusNoPaper,
- });
- buildApp().renderApp();
+ apiMock.expectGetConfig({ pollsState: 'polls_open' });
+ apiMock.expectGetScannerStatus(statusNoPaper);
+ renderApp();
await screen.findByText('Insert Your Ballot Below');
- await screen.findByText('Scan one ballot sheet at a time.');
- await screen.findByText('General Election');
- await screen.findByText(/Franklin County/);
- await screen.findByText(/State of Hamilton/);
- await screen.findByText('Election ID');
- await screen.findByText('748dc61ad3');
const interpretation: Scan.SheetInterpretation = {
type: 'NeedsReviewSheet',
reasons: [{ type: AdjudicationReason.BlankBallot }],
};
- fetchMock
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'ready_to_scan' }),
- })
- .post('/precinct-scanner/scanner/scan', { body: { status: 'ok' } })
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'scanning' }),
- })
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'needs_review', interpretation }),
- });
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'ready_to_scan' }));
+ apiMock.mockApiClient.scanBallot.expectCallWith().resolves();
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'scanning' }));
+ apiMock.expectGetScannerStatus(
+ scannerStatus({ state: 'needs_review', interpretation })
+ );
// trigger scan
jest.advanceTimersByTime(POLLING_INTERVAL_FOR_SCANNER_STATUS_MS);
@@ -594,17 +611,16 @@ test('voter can cast a ballot that needs review and adjudicate as desired', asyn
jest.advanceTimersByTime(POLLING_INTERVAL_FOR_SCANNER_STATUS_MS);
await screen.findByText('No votes were found when scanning this ballot.');
- fetchMock
- .post('/precinct-scanner/scanner/accept', { body: { status: 'ok' } })
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'accepting_after_review', interpretation }),
- })
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'accepted', interpretation }),
- })
- .get('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'no_paper', ballotsCounted: 1 }),
- });
+ apiMock.mockApiClient.acceptBallot.expectCallWith().resolves();
+ apiMock.expectGetScannerStatus(
+ scannerStatus({ state: 'accepting_after_review', interpretation })
+ );
+ apiMock.expectGetScannerStatus(
+ scannerStatus({ state: 'accepted', interpretation })
+ );
+ apiMock.expectGetScannerStatus(
+ scannerStatus({ state: 'no_paper', ballotsCounted: 1 })
+ );
userEvent.click(screen.getByRole('button', { name: 'Cast Ballot As Is' }));
await screen.findByText('Are you sure?');
@@ -617,40 +633,26 @@ test('voter can cast a ballot that needs review and adjudicate as desired', asyn
jest.advanceTimersByTime(POLLING_INTERVAL_FOR_SCANNER_STATUS_MS);
await screen.findByText('Insert Your Ballot Below');
expect(screen.getByTestId('ballot-count').textContent).toBe('1');
- expect(fetchMock.done()).toBe(true);
});
-test('voter can cast a rejected ballot', async () => {
- mockConfig({ pollsState: 'polls_open' });
- fetchMock.getOnce('/precinct-scanner/scanner/status', {
- body: statusNoPaper,
- });
- buildApp().renderApp();
+test('voter tries to cast ballot that is rejected', async () => {
+ apiMock.expectGetConfig({ pollsState: 'polls_open' });
+ apiMock.expectGetScannerStatus(statusNoPaper);
+ renderApp();
await screen.findByText('Insert Your Ballot Below');
- await screen.findByText('Scan one ballot sheet at a time.');
- await screen.findByText('General Election');
- await screen.findByText(/Franklin County/);
- await screen.findByText(/State of Hamilton/);
- await screen.findByText('Election ID');
- await screen.findByText('748dc61ad3');
-
- fetchMock
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'ready_to_scan' }),
- })
- .post('/precinct-scanner/scanner/scan', { body: { status: 'ok' } })
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'scanning' }),
+
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'ready_to_scan' }));
+ apiMock.mockApiClient.scanBallot.expectCallWith().resolves();
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'scanning' }));
+ apiMock.expectGetScannerStatus(
+ scannerStatus({
+ state: 'rejected',
+ interpretation: {
+ type: 'InvalidSheet',
+ reason: 'invalid_election_hash',
+ },
})
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({
- state: 'rejected',
- interpretation: {
- type: 'InvalidSheet',
- reason: 'invalid_election_hash',
- },
- }),
- });
+ );
jest.advanceTimersByTime(POLLING_INTERVAL_FOR_SCANNER_STATUS_MS);
await screen.findByText(/Please wait/);
@@ -659,87 +661,58 @@ test('voter can cast a rejected ballot', async () => {
screen.getByText(
'The ballot does not match the election this scanner is configured for.'
);
- expect(fetchMock.done()).toBe(true);
// When the voter removes the ballot return to the insert ballot screen
- fetchMock.getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'no_paper' }),
- });
+ apiMock.expectGetScannerStatus(statusNoPaper);
await screen.findByText('Insert Your Ballot Below');
- expect(fetchMock.done()).toBe(true);
});
test('voter can cast another ballot while the success screen is showing', async () => {
- mockConfig({ pollsState: 'polls_open' });
- fetchMock.getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'accepted', ballotsCounted: 1 }),
- });
- buildApp().renderApp();
+ apiMock.expectGetConfig({ pollsState: 'polls_open' });
+ apiMock.expectGetScannerStatus(
+ scannerStatus({ state: 'accepted', ballotsCounted: 1 })
+ );
+ renderApp();
await screen.findByText('Your ballot was counted!');
- await screen.findByText('General Election');
- await screen.findByText(/Franklin County/);
- await screen.findByText(/State of Hamilton/);
- await screen.findByText('Election ID');
- await screen.findByText('748dc61ad3');
-
- fetchMock.getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'accepted', ballotsCounted: 1 }),
- });
+
screen.getByText('Your ballot was counted!');
expect(screen.getByTestId('ballot-count').textContent).toBe('1');
- fetchMock
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'ready_to_scan' }),
- })
- .post('/precinct-scanner/scanner/scan', { body: { status: 'ok' } })
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'scanning' }),
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'ready_to_scan' }));
+ apiMock.mockApiClient.scanBallot.expectCallWith().resolves();
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'scanning' }));
+ apiMock.expectGetScannerStatus(
+ scannerStatus({
+ state: 'needs_review',
+ interpretation: {
+ type: 'NeedsReviewSheet',
+ reasons: [{ type: AdjudicationReason.BlankBallot }],
+ },
})
- .get('/precinct-scanner/scanner/status', {
- body: scannerStatus({
- state: 'needs_review',
- interpretation: {
- type: 'NeedsReviewSheet',
- reasons: [{ type: AdjudicationReason.BlankBallot }],
- },
- }),
- });
+ );
jest.advanceTimersByTime(POLLING_INTERVAL_FOR_SCANNER_STATUS_MS);
await screen.findByText(/Please wait/);
jest.advanceTimersByTime(POLLING_INTERVAL_FOR_SCANNER_STATUS_MS);
await screen.findByText('No votes were found when scanning this ballot.');
-
- expect(fetchMock.done()).toBe(true);
});
test('scanning is not triggered when polls closed or cards present', async () => {
- const { mockPollsChange } = mockConfig();
-
- fetchMock
- .get('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'ready_to_scan' }),
- })
- // Mock the scan endpoint just so we can check that we don't hit it
- .post('/precinct-scanner/scanner/scan', { status: 500 });
- const { card, renderApp } = buildApp();
- renderApp();
+ apiMock.expectGetConfig();
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'ready_to_scan' }), 3);
+ const { card } = renderApp();
await screen.findByText('Polls Closed');
fetchMock.post('/precinct-scanner/export', {});
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to open the polls?');
// Open Polls
- mockPollsChange('polls_open');
+ apiMock.expectSetPollsState('polls_open');
+ apiMock.expectGetConfig({ pollsState: 'polls_open' });
userEvent.click(screen.getByText('Yes, Open the Polls'));
await screen.findByText('Polls are open.');
// Once we remove the poll worker card, scanning should start
- fetchMock.post(
- '/precinct-scanner/scanner/scan',
- { body: { status: 'ok' } },
- { overwriteRoutes: true }
- );
+ apiMock.mockApiClient.scanBallot.expectCallWith().resolves();
card.removeCard();
jest.advanceTimersByTime(POLLING_INTERVAL_FOR_SCANNER_STATUS_MS);
await screen.findByText(/Please wait/);
@@ -747,17 +720,17 @@ test('scanning is not triggered when polls closed or cards present', async () =>
});
test('no printer: poll worker can open and close polls without scanning any ballots', async () => {
- const { mockPollsChange } = mockConfig();
- fetchMock.get('/precinct-scanner/scanner/status', { body: statusNoPaper });
- const { card, renderApp } = buildApp();
- renderApp();
+ apiMock.expectGetConfig();
+ apiMock.expectGetScannerStatus(statusNoPaper, 3);
+ const { card } = renderApp();
await screen.findByText('Polls Closed');
fetchMock.post('/precinct-scanner/export', {});
// Open Polls Flow
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to open the polls?');
- mockPollsChange('polls_open');
+ apiMock.expectSetPollsState('polls_open');
+ apiMock.expectGetConfig({ pollsState: 'polls_open' });
userEvent.click(await screen.findByText('Yes, Open the Polls'));
await screen.findByText(
'Insert poll worker card into VxMark to print the report.'
@@ -768,7 +741,8 @@ test('no printer: poll worker can open and close polls without scanning any ball
// Close Polls Flow
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to close the polls?');
- mockPollsChange('polls_closed_final');
+ apiMock.expectSetPollsState('polls_closed_final');
+ apiMock.expectGetConfig({ pollsState: 'polls_closed_final' });
userEvent.click(await screen.findByText('Yes, Close the Polls'));
await screen.findByText('Closing Polls…');
await screen.findByText(
@@ -779,17 +753,21 @@ test('no printer: poll worker can open and close polls without scanning any ball
});
test('with printer: poll worker can open and close polls without scanning any ballots', async () => {
- const { mockPollsChange } = mockConfig();
- fetchMock.get('/precinct-scanner/scanner/status', { body: statusNoPaper });
- const { card, renderApp } = buildApp(true);
- renderApp();
+ apiMock.expectGetConfig({ pollsState: 'polls_closed_initial' });
+ apiMock.expectGetScannerStatus(statusNoPaper, 5);
+ const hardware = MemoryHardware.build({
+ connectCardReader: true,
+ connectPrinter: true,
+ });
+ const { card } = renderApp({ hardware });
await screen.findByText('Polls Closed');
- fetchMock.post('/precinct-scanner/export', {});
// Open Polls Flow
+ fetchMock.post('/precinct-scanner/export', {});
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to open the polls?');
- mockPollsChange('polls_open');
+ apiMock.expectSetPollsState('polls_open');
+ apiMock.expectGetConfig({ pollsState: 'polls_open' });
userEvent.click(screen.getByRole('button', { name: 'Yes, Open the Polls' }));
await screen.findByText('Polls are open.');
await expectPrint();
@@ -808,7 +786,8 @@ test('with printer: poll worker can open and close polls without scanning any ba
// Close Polls Flow
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to close the polls?');
- mockPollsChange('polls_closed_final');
+ apiMock.expectSetPollsState('polls_closed_final');
+ apiMock.expectGetConfig({ pollsState: 'polls_closed_final' });
userEvent.click(screen.getByRole('button', { name: 'Yes, Close the Polls' }));
await screen.findByText('Polls are closed.');
await expectPrint();
@@ -826,18 +805,18 @@ test('with printer: poll worker can open and close polls without scanning any ba
});
test('no printer: open polls, scan ballot, close polls, save results', async () => {
- const { mockPollsChange } = mockConfig();
- fetchMock.get('/precinct-scanner/scanner/status', { body: statusNoPaper });
- const { card, renderApp } = buildApp();
+ apiMock.expectGetConfig();
+ apiMock.expectGetScannerStatus(statusNoPaper, 2);
+ const { card } = renderApp();
const writeLongObjectMock = jest.spyOn(card, 'writeLongObject');
- renderApp();
await screen.findByText('Polls Closed');
- fetchMock.post('/precinct-scanner/export', {});
// Open Polls Flow
+ fetchMock.post('/precinct-scanner/export', {});
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to open the polls?');
- mockPollsChange('polls_open');
+ apiMock.expectSetPollsState('polls_open');
+ apiMock.expectGetConfig({ pollsState: 'polls_open' });
userEvent.click(await screen.findByText('Yes, Open the Polls'));
expect(writeLongObjectMock).toHaveBeenCalledTimes(1);
await screen.findByText(
@@ -847,26 +826,18 @@ test('no printer: open polls, scan ballot, close polls, save results', async ()
await screen.findByText('Insert Your Ballot Below');
// Voter scans a ballot
- fetchMock
- .getOnce(
- '/precinct-scanner/scanner/status',
- { body: scannerStatus({ state: 'ready_to_scan' }) },
- { overwriteRoutes: true }
- )
- .post('/precinct-scanner/scanner/scan', { body: { status: 'ok' } })
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'scanning' }),
- })
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'ready_to_accept' }),
- })
- .post('/precinct-scanner/scanner/accept', { body: { status: 'ok' } })
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'accepted' }),
- })
- .get('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'no_paper', ballotsCounted: 1 }),
- });
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'ready_to_scan' }));
+ apiMock.mockApiClient.scanBallot.expectCallWith().resolves();
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'scanning' }));
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'ready_to_accept' }));
+ apiMock.mockApiClient.acceptBallot.expectCallWith().resolves();
+ apiMock.mockApiClient.acceptBallot.expectCallWith().resolves();
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'accepted' }));
+ const statusBallotCounted = scannerStatus({
+ state: 'no_paper',
+ ballotsCounted: 1,
+ });
+ apiMock.expectGetScannerStatus(statusBallotCounted);
// trigger scan
await advanceTimersAndPromises(POLLING_INTERVAL_FOR_SCANNER_STATUS_MS / 1000);
@@ -878,9 +849,9 @@ test('no printer: open polls, scan ballot, close polls, save results', async ()
await advanceTimersAndPromises(POLLING_INTERVAL_FOR_SCANNER_STATUS_MS / 1000);
await screen.findByText('Scan one ballot sheet at a time.');
expect((await screen.findByTestId('ballot-count')).textContent).toBe('1');
- expect(fetchMock.done()).toBe(true);
// Close Polls
+ apiMock.expectGetScannerStatus(statusBallotCounted, 2);
fetchMock.post(
'/precinct-scanner/export',
{
@@ -896,7 +867,8 @@ test('no printer: open polls, scan ballot, close polls, save results', async ()
);
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to close the polls?');
- mockPollsChange('polls_closed_final');
+ apiMock.expectSetPollsState('polls_closed_final');
+ apiMock.expectGetConfig({ pollsState: 'polls_closed_final' });
userEvent.click(await screen.findByText('Yes, Close the Polls'));
await screen.findByText('Closing Polls…');
await screen.findByText('Polls are closed.');
@@ -940,17 +912,17 @@ test('no printer: open polls, scan ballot, close polls, save results', async ()
});
test('poll worker can open, pause, unpause, and close poll without scanning any ballots', async () => {
- const { mockPollsChange } = mockConfig();
- fetchMock.get('/precinct-scanner/scanner/status', { body: statusNoPaper });
- const { card, renderApp } = buildApp();
- renderApp();
+ apiMock.expectGetConfig();
+ apiMock.expectGetScannerStatus(statusNoPaper, 5);
+ const { card } = renderApp();
await screen.findByText('Polls Closed');
- fetchMock.post('/precinct-scanner/export', {});
// Open Polls
+ fetchMock.post('/precinct-scanner/export', {});
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to open the polls?');
- mockPollsChange('polls_open');
+ apiMock.expectSetPollsState('polls_open');
+ apiMock.expectGetConfig({ pollsState: 'polls_open' });
userEvent.click(await screen.findByText('Yes, Open the Polls'));
await screen.findByText(
'Insert poll worker card into VxMark to print the report.'
@@ -962,7 +934,8 @@ test('poll worker can open, pause, unpause, and close poll without scanning any
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to close the polls?');
userEvent.click(await screen.findByText('No'));
- mockPollsChange('polls_paused');
+ apiMock.expectSetPollsState('polls_paused');
+ apiMock.expectGetConfig({ pollsState: 'polls_paused' });
userEvent.click(await screen.findByText('Pause Voting'));
await screen.findByText('Pausing Voting…');
await screen.findByText(
@@ -974,7 +947,8 @@ test('poll worker can open, pause, unpause, and close poll without scanning any
// Resume Voting Flow
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to resume voting?');
- mockPollsChange('polls_open');
+ apiMock.expectSetPollsState('polls_open');
+ apiMock.expectGetConfig({ pollsState: 'polls_open' });
userEvent.click(await screen.findByText('Yes, Resume Voting'));
await screen.findByText(
'Insert poll worker card into VxMark to print the report.'
@@ -985,7 +959,8 @@ test('poll worker can open, pause, unpause, and close poll without scanning any
// Close Polls Flow
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to close the polls?');
- mockPollsChange('polls_closed_final');
+ apiMock.expectSetPollsState('polls_closed_final');
+ apiMock.expectGetConfig({ pollsState: 'polls_closed_final' });
userEvent.click(await screen.findByText('Yes, Close the Polls'));
await screen.findByText('Closing Polls…');
await screen.findByText(
@@ -996,14 +971,9 @@ test('poll worker can open, pause, unpause, and close poll without scanning any
});
test('system administrator can log in and unconfigure machine', async () => {
- mockConfig();
- fetchMock
- .get('/precinct-scanner/scanner/status', { body: statusNoPaper })
- .delete('/precinct-scanner/config/election?ignoreBackupRequirement=true', {
- body: deleteElectionConfigResponseBody,
- });
- const { card, renderApp } = buildApp();
- renderApp();
+ apiMock.expectGetConfig();
+ apiMock.expectGetScannerStatus(statusNoPaper, 2);
+ const { card } = renderApp();
card.insertCard(makeSystemAdministratorCard());
await screen.findByText('Enter the card security code to unlock.');
@@ -1020,6 +990,10 @@ test('system administrator can log in and unconfigure machine', async () => {
name: 'Unconfigure Machine',
});
+ apiMock.mockApiClient.unconfigureElection
+ .expectCallWith({ ignoreBackupRequirement: true })
+ .resolves();
+ apiMock.expectGetConfig({ electionDefinition: undefined });
userEvent.click(unconfigureMachineButton);
const modal = await screen.findByRole('alertdialog');
userEvent.click(
@@ -1033,31 +1007,20 @@ test('system administrator can log in and unconfigure machine', async () => {
});
test('system administrator allowed to log in on unconfigured machine', async () => {
- mockConfig();
- fetchMock
- .get(
- '/precinct-scanner/config/election',
- { body: null },
- { overwriteRoutes: true }
- )
- .get('/precinct-scanner/scanner/status', { body: statusNoPaper });
+ apiMock.expectGetConfig({ electionDefinition: undefined });
- const { card, renderApp } = buildApp();
- renderApp();
+ const { card } = renderApp();
card.insertCard(makeSystemAdministratorCard());
await screen.findByText('Enter the card security code to unlock.');
});
test('system administrator can reset polls to paused', async () => {
- const { mockPollsChange } = mockConfig({ pollsState: 'polls_closed_final' });
- fetchMock
- .get('/precinct-scanner/scanner/status', { body: statusNoPaper })
- .delete('/precinct-scanner/config/election?ignoreBackupRequirement=true', {
- body: deleteElectionConfigResponseBody,
- });
- const { card, renderApp } = buildApp();
- renderApp();
+ apiMock.expectGetConfig({
+ pollsState: 'polls_closed_final',
+ });
+ apiMock.expectGetScannerStatus(statusNoPaper, 2);
+ const { card } = renderApp();
await screen.findByText('Polls Closed');
card.insertCard(makeSystemAdministratorCard());
@@ -1073,7 +1036,8 @@ test('system administrator can reset polls to paused', async () => {
await screen.findByRole('button', { name: 'Reset Polls to Paused' })
);
const modal = await screen.findByRole('alertdialog');
- mockPollsChange('polls_paused');
+ apiMock.expectSetPollsState('polls_paused');
+ apiMock.expectGetConfig({ pollsState: 'polls_paused' });
userEvent.click(
await within(modal).findByRole('button', { name: 'Reset Polls to Paused' })
);
@@ -1085,11 +1049,9 @@ test('system administrator can reset polls to paused', async () => {
});
test('election manager cannot auth onto machine with different election hash', async () => {
- mockConfig();
- fetchMock.get('/precinct-scanner/scanner/status', { body: statusNoPaper });
-
- const { card, renderApp } = buildApp();
- renderApp();
+ apiMock.expectGetConfig();
+ apiMock.expectGetScannerStatus(statusNoPaper);
+ const { card } = renderApp();
card.insertCard(
makeElectionManagerCard(electionSample2Definition.electionHash)
@@ -1098,35 +1060,26 @@ test('election manager cannot auth onto machine with different election hash', a
});
test('replace ballot bag flow', async () => {
- const { mockBallotBagReplaced } = mockConfig({ pollsState: 'polls_open' });
- fetchMock.getOnce('/precinct-scanner/scanner/status', {
- body: statusNoPaper,
+ apiMock.expectGetConfig({
+ pollsState: 'polls_open',
});
- const { card, logger, renderApp } = buildApp();
- renderApp();
+ apiMock.expectGetScannerStatus(statusNoPaper);
+ const { card, logger } = renderApp();
await screen.findByText('Insert Your Ballot Below');
- fetchMock
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'ready_to_scan' }),
- })
- .post('/precinct-scanner/scanner/scan', { body: { status: 'ok' } })
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'scanning' }),
- })
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'ready_to_accept' }),
- })
- .post('/precinct-scanner/scanner/accept', { body: { status: 'ok' } })
- .getOnce('/precinct-scanner/scanner/status', {
- body: scannerStatus({ state: 'accepted' }),
- })
- .get('/precinct-scanner/scanner/status', {
- body: scannerStatus({
- state: 'no_paper',
- ballotsCounted: BALLOT_BAG_CAPACITY,
- }),
- });
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'ready_to_scan' }));
+ apiMock.mockApiClient.scanBallot.expectCallWith().resolves();
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'scanning' }));
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'ready_to_accept' }));
+ apiMock.mockApiClient.acceptBallot.expectCallWith().resolves();
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'accepted' }));
+ apiMock.expectGetScannerStatus(
+ scannerStatus({
+ state: 'no_paper',
+ ballotsCounted: BALLOT_BAG_CAPACITY,
+ }),
+ 6
+ );
// trigger scan
await advanceTimersAndPromises(POLLING_INTERVAL_FOR_SCANNER_STATUS_MS / 1000);
@@ -1156,7 +1109,6 @@ test('replace ballot bag flow', async () => {
card.insertCard(pollWorkerCard);
await advanceTimersAndPromises(1);
await screen.findByText('Ballot Bag Replaced?');
- mockBallotBagReplaced(BALLOT_BAG_CAPACITY);
userEvent.click(screen.getByText('Yes, New Ballot Bag is Ready'));
// Prompted to remove card
@@ -1164,6 +1116,11 @@ test('replace ballot bag flow', async () => {
await screen.findByText('Remove card to resume voting.');
// Removing card returns to voter screen
+ apiMock.mockApiClient.recordBallotBagReplaced.expectCallWith().resolves();
+ apiMock.expectGetConfig({
+ pollsState: 'polls_open',
+ ballotCountWhenBallotBagLastReplaced: BALLOT_BAG_CAPACITY,
+ });
card.removeCard();
await advanceTimersAndPromises(1);
await screen.findByText('Insert Your Ballot Below');
@@ -1175,24 +1132,22 @@ test('replace ballot bag flow', async () => {
);
// Does not prompt again if new threshold hasn't been reached
- fetchMock.restore();
- fetchMock.get('/precinct-scanner/scanner/status', {
- body: scannerStatus({
+ apiMock.expectGetScannerStatus(
+ scannerStatus({
state: 'no_paper',
ballotsCounted: BALLOT_BAG_CAPACITY * 2 - 1,
- }),
- });
+ })
+ );
await advanceTimersAndPromises(1);
await screen.findByText('Insert Your Ballot Below');
// Prompts again if new threshold has been reached
- fetchMock.restore();
- fetchMock.get('/precinct-scanner/scanner/status', {
- body: scannerStatus({
+ apiMock.expectGetScannerStatus(
+ scannerStatus({
state: 'no_paper',
ballotsCounted: BALLOT_BAG_CAPACITY * 2,
- }),
- });
+ })
+ );
await advanceTimersAndPromises(1);
await screen.findByText('Ballot Bag Full');
});
diff --git a/apps/vx-scan/frontend/src/app.tsx b/apps/vx-scan/frontend/src/app.tsx
index 20bb04b15..69aaed47f 100644
--- a/apps/vx-scan/frontend/src/app.tsx
+++ b/apps/vx-scan/frontend/src/app.tsx
@@ -10,16 +10,21 @@ import {
getHardware,
} from '@votingworks/utils';
import { Logger, LogSource } from '@votingworks/logging';
+import * as grout from '@votingworks/grout';
+// eslint-disable-next-line vx/gts-no-import-export-type
+import type { Api } from '@votingworks/vx-scan-backend';
import { AppRoot, Props as AppRootProps } from './app_root';
import { machineConfigProvider } from './utils/machine_config';
+import { ApiClientContext } from './api/api';
-export interface Props {
+export interface AppProps {
hardware?: AppRootProps['hardware'];
card?: AppRootProps['card'];
machineConfig?: AppRootProps['machineConfig'];
storage?: AppRootProps['storage'];
logger?: AppRootProps['logger'];
+ apiClient?: grout.Client;
}
export function App({
@@ -28,16 +33,19 @@ export function App({
storage = window.kiosk ? new KioskStorage(window.kiosk) : new LocalStorage(),
machineConfig = machineConfigProvider,
logger = new Logger(LogSource.VxScanFrontend, window.kiosk),
-}: Props): JSX.Element {
+ apiClient = grout.createClient({ baseUrl: '/api' }),
+}: AppProps): JSX.Element {
return (
-
+
+
+
);
}
diff --git a/apps/vx-scan/frontend/src/app_root.tsx b/apps/vx-scan/frontend/src/app_root.tsx
index 670dd5891..ea96c8e50 100644
--- a/apps/vx-scan/frontend/src/app_root.tsx
+++ b/apps/vx-scan/frontend/src/app_root.tsx
@@ -34,9 +34,6 @@ import { UnconfiguredElectionScreen } from './screens/unconfigured_election_scre
import { LoadingConfigurationScreen } from './screens/loading_configuration_screen';
import { MachineConfig } from './config/types';
-import * as config from './api/config';
-import * as scanner from './api/scan';
-
import { usePrecinctScannerStatus } from './hooks/use_precinct_scanner_status';
import { ElectionManagerScreen } from './screens/election_manager_screen';
import { InvalidCardScreen } from './screens/invalid_card_screen';
@@ -59,6 +56,7 @@ import { ReplaceBallotBagScreen } from './components/replace_ballot_bag_screen';
import { BALLOT_BAG_CAPACITY } from './config/globals';
import { UnconfiguredPrecinctScreen } from './screens/unconfigured_precinct_screen';
import { rootDebug } from './utils/debug';
+import { useApiClient } from './api/api';
const debug = rootDebug.extend('app-root');
@@ -177,6 +175,7 @@ export function AppRoot({
machineConfig: machineConfigProvider,
logger,
}: Props): JSX.Element | null {
+ const apiClient = useApiClient();
const [appState, dispatchAppState] = useReducer(appReducer, initialState);
const {
electionDefinition,
@@ -216,13 +215,13 @@ export function AppRoot({
const makeCancelable = useCancelablePromise();
const refreshConfig = useCallback(async () => {
- const scannerConfig = await makeCancelable(config.get());
+ const scannerConfig = await makeCancelable(apiClient.getConfig());
dispatchAppState({
type: 'refreshStateFromBackend',
scannerConfig,
});
- }, [makeCancelable]);
+ }, [makeCancelable, apiClient]);
// Handle Machine Config
useEffect(() => {
@@ -256,11 +255,11 @@ export function AppRoot({
const updatePollsState = useCallback(
async (newPollsState: PollsState) => {
- await config.setPollsState(newPollsState);
+ await apiClient.setPollsState({ pollsState: newPollsState });
dispatchAppState({ type: 'updatePollsState', pollsState: newPollsState });
await refreshConfig();
},
- [refreshConfig]
+ [refreshConfig, apiClient]
);
const resetPollsToPaused = useCallback(async () => {
@@ -268,33 +267,35 @@ export function AppRoot({
}, [updatePollsState]);
const toggleTestMode = useCallback(async () => {
- await config.setTestMode(!isTestMode);
+ await apiClient.setTestMode({ isTestMode: !isTestMode });
dispatchAppState({ type: 'updateTestMode', isTestMode: !isTestMode });
dispatchAppState({ type: 'resetElectionSession' });
await refreshConfig();
- }, [refreshConfig, isTestMode]);
+ }, [refreshConfig, isTestMode, apiClient]);
const toggleIsSoundMuted = useCallback(async () => {
dispatchAppState({ type: 'toggleIsSoundMuted' });
- await config.setIsSoundMuted(!isSoundMuted);
- }, [isSoundMuted]);
+ await apiClient.setIsSoundMuted({ isSoundMuted: !isSoundMuted });
+ }, [isSoundMuted, apiClient]);
const unconfigureServer = useCallback(
async (options: { ignoreBackupRequirement?: boolean } = {}) => {
try {
- await config.setElection(undefined, options);
+ await apiClient.unconfigureElection(options);
await refreshConfig();
} catch (error) {
debug('failed unconfigureServer()', error);
}
},
- [refreshConfig]
+ [refreshConfig, apiClient]
);
async function updatePrecinctSelection(
newPrecinctSelection: PrecinctSelection
) {
- await config.setPrecinctSelection(newPrecinctSelection);
+ await apiClient.setPrecinctSelection({
+ precinctSelection: newPrecinctSelection,
+ });
dispatchAppState({
type: 'updatePrecinctSelection',
precinctSelection: newPrecinctSelection,
@@ -307,19 +308,21 @@ export function AppRoot({
type: 'updateMarkThresholdOverrides',
markThresholdOverrides: markThresholds,
});
- await config.setMarkThresholdOverrides(markThresholds);
+ await apiClient.setMarkThresholdOverrides({
+ markThresholdOverrides: markThresholds,
+ });
}
const scannerStatus = usePrecinctScannerStatus();
const onBallotBagReplaced = useCallback(async () => {
- await config.setBallotBagReplaced();
+ await apiClient.recordBallotBagReplaced();
await refreshConfig();
await logger.log(LogEventId.BallotBagReplaced, 'poll_worker', {
disposition: 'success',
message: 'Poll worker confirmed that they replaced the ballot bag.',
});
- }, [logger, refreshConfig]);
+ }, [logger, refreshConfig, apiClient]);
const needsToReplaceBallotBag =
scannerStatus &&
@@ -339,9 +342,9 @@ export function AppRoot({
}
if (scannerStatus?.state === 'ready_to_scan') {
- await scanner.scanBallot();
+ await apiClient.scanBallot();
} else if (scannerStatus?.state === 'ready_to_accept') {
- await scanner.acceptBallot();
+ await apiClient.acceptBallot();
}
}
void automaticallyScanAndAcceptBallots();
diff --git a/apps/vx-scan/frontend/src/app_tally_report_paths.test.tsx b/apps/vx-scan/frontend/src/app_tally_report_paths.test.tsx
index 08d12e3dc..1a1425714 100644
--- a/apps/vx-scan/frontend/src/app_tally_report_paths.test.tsx
+++ b/apps/vx-scan/frontend/src/app_tally_report_paths.test.tsx
@@ -1,5 +1,6 @@
+import React from 'react';
import fetchMock from 'fetch-mock';
-import { screen, within } from '@testing-library/react';
+import { render, screen, within } from '@testing-library/react';
import {
electionMinimalExhaustiveSample,
electionMinimalExhaustiveSampleDefinition,
@@ -19,12 +20,14 @@ import {
expectPrint,
hasTextAcrossElements,
} from '@votingworks/test-utils';
-import { Scan } from '@votingworks/api';
import {
ALL_PRECINCTS_SELECTION,
BallotCountDetails,
singlePrecinctSelectionFor,
ReportSourceMachineType,
+ MemoryCard,
+ MemoryHardware,
+ MemoryStorage,
} from '@votingworks/utils';
import {
CompressedTally,
@@ -36,32 +39,19 @@ import {
import userEvent from '@testing-library/user-event';
import MockDate from 'mockdate';
+import { fakeLogger } from '@votingworks/logging';
import { MachineConfigResponse } from './config/types';
import { fakeFileWriter } from '../test/helpers/fake_file_writer';
-import { buildApp } from '../test/helpers/build_app';
-import { mockConfig } from '../test/helpers/mock_config';
+import { App } from './app';
+import { createApiMock, statusNoPaper } from '../test/helpers/mock_api_client';
+
+const apiMock = createApiMock();
const getMachineConfigBody: MachineConfigResponse = {
machineId: '0002',
codeVersion: '3.14',
};
-const getTestModeConfigTrueResponseBody: Scan.GetTestModeConfigResponse = {
- status: 'ok',
- testMode: true,
-};
-
-const getMarkThresholdOverridesConfigNoMarkThresholdOverridesResponseBody: Scan.GetMarkThresholdOverridesConfigResponse =
- {
- status: 'ok',
- };
-
-const statusNoPaper: Scan.GetPrecinctScannerStatusResponse = {
- state: 'no_paper',
- canUnconfigure: false,
- ballotsCounted: 0,
-};
-
function expectBallotCountsInReport(
container: HTMLElement,
precinctBallots: number,
@@ -97,17 +87,32 @@ function expectContestResultsInReport(
}
}
+function renderApp({ connectPrinter }: { connectPrinter: boolean }) {
+ const card = new MemoryCard();
+ const hardware = MemoryHardware.build({
+ connectPrinter,
+ connectCardReader: true,
+ connectPrecinctScanner: true,
+ });
+ const logger = fakeLogger();
+ const storage = new MemoryStorage();
+ render(
+
+ );
+ return { card, hardware, logger, storage };
+}
+
beforeEach(() => {
jest.useFakeTimers();
fetchMock.reset();
- fetchMock
- .get('/machine-config', { body: getMachineConfigBody })
- .get('/precinct-scanner/config/testMode', {
- body: getTestModeConfigTrueResponseBody,
- })
- .get('/precinct-scanner/config/markThresholdOverrides', {
- body: getMarkThresholdOverridesConfigNoMarkThresholdOverridesResponseBody,
- });
+ fetchMock.get('/machine-config', { body: getMachineConfigBody });
+ apiMock.mockApiClient.reset();
const kiosk = fakeKiosk();
kiosk.getUsbDrives.mockResolvedValue([fakeUsbDrive()]);
@@ -117,24 +122,25 @@ beforeEach(() => {
window.kiosk = kiosk;
});
+afterEach(() => {
+ apiMock.mockApiClient.assertComplete();
+});
+
test('printing: polls open, All Precincts, primary election + check additional report', async () => {
- const { election } = electionMinimalExhaustiveSampleDefinition;
- const { mockPollsChange } = mockConfig({
- electionDefinition: electionMinimalExhaustiveSampleDefinition,
- });
- fetchMock.get('/precinct-scanner/scanner/status', { body: statusNoPaper });
- const { card, renderApp } = buildApp(true);
- renderApp();
+ const electionDefinition = electionMinimalExhaustiveSampleDefinition;
+ const { election } = electionDefinition;
+ apiMock.expectGetConfig({ electionDefinition });
+ apiMock.expectGetScannerStatus(statusNoPaper, 3);
+ const { card } = renderApp({ connectPrinter: true });
await screen.findByText('Polls Closed');
// Open the polls
fetchMock.post('/precinct-scanner/export', {});
- const pollWorkerCard = makePollWorkerCard(
- electionMinimalExhaustiveSampleDefinition.electionHash
- );
+ const pollWorkerCard = makePollWorkerCard(electionDefinition.electionHash);
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to open the polls?');
- mockPollsChange('polls_open');
+ apiMock.expectSetPollsState('polls_open');
+ apiMock.expectGetConfig({ electionDefinition, pollsState: 'polls_open' });
userEvent.click(screen.getByText('Yes, Open the Polls'));
await screen.findByText('Polls are open.');
@@ -170,20 +176,16 @@ test('printing: polls open, All Precincts, primary election + check additional r
});
test('saving to card: polls open, All Precincts, primary election + test failed card write', async () => {
- const { mockPollsChange } = mockConfig({
- electionDefinition: electionMinimalExhaustiveSampleDefinition,
- });
- fetchMock.get('/precinct-scanner/scanner/status', { body: statusNoPaper });
- const { card, renderApp } = buildApp();
+ const electionDefinition = electionMinimalExhaustiveSampleDefinition;
+ apiMock.expectGetConfig({ electionDefinition });
+ apiMock.expectGetScannerStatus(statusNoPaper, 3);
+ const { card } = renderApp({ connectPrinter: false });
const writeLongObjectMock = jest.spyOn(card, 'writeLongObject');
- renderApp();
await screen.findByText('Polls Closed');
// Open the polls
fetchMock.post('/precinct-scanner/export', {});
- const pollWorkerCard = makePollWorkerCard(
- electionMinimalExhaustiveSampleDefinition.electionHash
- );
+ const pollWorkerCard = makePollWorkerCard(electionDefinition.electionHash);
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to open the polls?');
// Mimic what would happen if the tallies by precinct didn't fit on the card but the overall tally does.
@@ -191,7 +193,8 @@ test('saving to card: polls open, All Precincts, primary election + test failed
jest
.spyOn(card, 'readLongObject')
.mockResolvedValue(err(new Error('bad read')));
- mockPollsChange('polls_open');
+ apiMock.expectSetPollsState('polls_open');
+ apiMock.expectGetConfig({ electionDefinition, pollsState: 'polls_open' });
userEvent.click(screen.getByText('Yes, Open the Polls'));
await screen.findByText('Polls are open.');
card.removeCard();
@@ -312,28 +315,24 @@ const PRIMARY_ALL_PRECINCTS_CVRS = generateFileContentFromCvrs([
]);
test('printing: polls closed, primary election, all precincts + quickresults on', async () => {
- const { election } =
+ const electionDefinition =
electionMinimalExhaustiveSampleWithReportingUrlDefinition;
- const { mockPollsChange } = mockConfig({
- electionDefinition:
- electionMinimalExhaustiveSampleWithReportingUrlDefinition,
- pollsState: 'polls_open',
- });
- fetchMock.get('/precinct-scanner/scanner/status', {
- body: { ...statusNoPaper, ballotsCounted: 3 },
- });
- const { card, renderApp } = buildApp(true);
- renderApp();
+ const { election } = electionDefinition;
+ apiMock.expectGetConfig({ electionDefinition, pollsState: 'polls_open' });
+ apiMock.expectGetScannerStatus({ ...statusNoPaper, ballotsCounted: 3 }, 3);
+ const { card } = renderApp({ connectPrinter: true });
await screen.findByText('Insert Your Ballot Below');
// Close the polls
fetchMock.post('/precinct-scanner/export', PRIMARY_ALL_PRECINCTS_CVRS);
- const pollWorkerCard = makePollWorkerCard(
- electionMinimalExhaustiveSampleWithReportingUrlDefinition.electionHash
- );
+ const pollWorkerCard = makePollWorkerCard(electionDefinition.electionHash);
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to close the polls?');
- mockPollsChange('polls_closed_final');
+ apiMock.expectSetPollsState('polls_closed_final');
+ apiMock.expectGetConfig({
+ electionDefinition,
+ pollsState: 'polls_closed_final',
+ });
userEvent.click(await screen.findByText('Yes, Close the Polls'));
await screen.findByText('Polls are closed.');
await expectPrint((printedElement) => {
@@ -520,27 +519,24 @@ test('printing: polls closed, primary election, all precincts + quickresults on'
});
test('saving to card: polls closed, primary election, all precincts', async () => {
- const { mockPollsChange } = mockConfig({
- electionDefinition:
- electionMinimalExhaustiveSampleWithReportingUrlDefinition,
- pollsState: 'polls_open',
- });
- fetchMock.get('/precinct-scanner/scanner/status', {
- body: { ...statusNoPaper, ballotsCounted: 3 },
- });
- const { card, renderApp } = buildApp();
+ const electionDefinition =
+ electionMinimalExhaustiveSampleWithReportingUrlDefinition;
+ apiMock.expectGetConfig({ electionDefinition, pollsState: 'polls_open' });
+ apiMock.expectGetScannerStatus({ ...statusNoPaper, ballotsCounted: 3 }, 4);
+ const { card } = renderApp({ connectPrinter: false });
const writeLongObjectMock = jest.spyOn(card, 'writeLongObject');
- renderApp();
await screen.findByText('Insert Your Ballot Below');
// Close the polls
fetchMock.post('/precinct-scanner/export', PRIMARY_ALL_PRECINCTS_CVRS);
- const pollWorkerCard = makePollWorkerCard(
- electionMinimalExhaustiveSampleWithReportingUrlDefinition.electionHash
- );
+ const pollWorkerCard = makePollWorkerCard(electionDefinition.electionHash);
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to close the polls?');
- mockPollsChange('polls_closed_final');
+ apiMock.expectSetPollsState('polls_closed_final');
+ apiMock.expectGetConfig({
+ electionDefinition,
+ pollsState: 'polls_closed_final',
+ });
userEvent.click(await screen.findByText('Yes, Close the Polls'));
await screen.findByText('Polls are closed.');
card.removeCard();
@@ -642,27 +638,28 @@ const PRIMARY_SINGLE_PRECINCT_CVRS = generateFileContentFromCvrs([
]);
test('printing: polls closed, primary election, single precinct + check additional report', async () => {
- const { election } = electionMinimalExhaustiveSampleDefinition;
- const { mockPollsChange } = mockConfig({
- electionDefinition: electionMinimalExhaustiveSampleDefinition,
+ const electionDefinition = electionMinimalExhaustiveSampleDefinition;
+ const { election } = electionDefinition;
+ apiMock.expectGetConfig({
+ electionDefinition,
precinctSelection: singlePrecinctSelectionFor('precinct-1'),
pollsState: 'polls_open',
});
- fetchMock.get('/precinct-scanner/scanner/status', {
- body: { ...statusNoPaper, ballotsCounted: 3 },
- });
- const { card, renderApp } = buildApp(true);
- renderApp();
+ apiMock.expectGetScannerStatus({ ...statusNoPaper, ballotsCounted: 3 }, 3);
+ const { card } = renderApp({ connectPrinter: true });
await screen.findByText('Insert Your Ballot Below');
// Close the polls
fetchMock.post('/precinct-scanner/export', PRIMARY_SINGLE_PRECINCT_CVRS);
- const pollWorkerCard = makePollWorkerCard(
- electionMinimalExhaustiveSampleDefinition.electionHash
- );
+ const pollWorkerCard = makePollWorkerCard(electionDefinition.electionHash);
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to close the polls?');
- mockPollsChange('polls_closed_final');
+ apiMock.expectSetPollsState('polls_closed_final');
+ apiMock.expectGetConfig({
+ electionDefinition,
+ precinctSelection: singlePrecinctSelectionFor('precinct-1'),
+ pollsState: 'polls_closed_final',
+ });
userEvent.click(await screen.findByText('Yes, Close the Polls'));
await screen.findByText('Polls are closed.');
async function checkReport() {
@@ -767,6 +764,10 @@ test('printing: polls closed, primary election, single precinct + check addition
}
await checkReport();
+ apiMock.expectGetScannerStatus({
+ ...statusNoPaper,
+ ballotsCounted: 3,
+ });
userEvent.click(screen.getByText('Print Additional Polls Closed Report'));
await screen.findByText('Printing Report…');
await advanceTimersAndPromises(4);
@@ -775,28 +776,29 @@ test('printing: polls closed, primary election, single precinct + check addition
});
test('saving to card: polls closed, primary election, single precinct', async () => {
- const { mockPollsChange } = mockConfig({
- electionDefinition: electionMinimalExhaustiveSampleDefinition,
+ const electionDefinition = electionMinimalExhaustiveSampleDefinition;
+ apiMock.expectGetConfig({
+ electionDefinition,
precinctSelection: singlePrecinctSelectionFor('precinct-1'),
pollsState: 'polls_open',
});
- fetchMock.get('/precinct-scanner/scanner/status', {
- body: { ...statusNoPaper, ballotsCounted: 3 },
- });
- const { card, renderApp } = buildApp();
+ apiMock.expectGetScannerStatus({ ...statusNoPaper, ballotsCounted: 3 }, 4);
+ const { card } = renderApp({ connectPrinter: false });
const writeLongObjectMock = jest.spyOn(card, 'writeLongObject');
- renderApp();
await screen.findByText('Insert Your Ballot Below');
// Close the polls
fetchMock.post('/precinct-scanner/export', PRIMARY_SINGLE_PRECINCT_CVRS);
- const pollWorkerCard = makePollWorkerCard(
- electionMinimalExhaustiveSampleDefinition.electionHash
- );
+ const pollWorkerCard = makePollWorkerCard(electionDefinition.electionHash);
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to close the polls?');
// Save the tally to card and get the expected tallies
- mockPollsChange('polls_closed_final');
+ apiMock.expectSetPollsState('polls_closed_final');
+ apiMock.expectGetConfig({
+ electionDefinition,
+ precinctSelection: singlePrecinctSelectionFor('precinct-1'),
+ pollsState: 'polls_closed_final',
+ });
userEvent.click(await screen.findByText('Yes, Close the Polls'));
await screen.findByText('Polls are closed.');
card.removeCard();
@@ -864,25 +866,22 @@ const GENERAL_ALL_PRECINCTS_CVRS = generateFileContentFromCvrs([
]);
test('printing: polls closed, general election, all precincts', async () => {
- const { mockPollsChange } = mockConfig({
- electionDefinition: electionSample2Definition,
- pollsState: 'polls_open',
- });
- fetchMock.get('/precinct-scanner/scanner/status', {
- body: { ...statusNoPaper, ballotsCounted: 2 },
- });
- const { card, renderApp } = buildApp(true);
- renderApp();
+ const electionDefinition = electionSample2Definition;
+ apiMock.expectGetConfig({ electionDefinition, pollsState: 'polls_open' });
+ apiMock.expectGetScannerStatus({ ...statusNoPaper, ballotsCounted: 2 }, 3);
+ const { card } = renderApp({ connectPrinter: true });
await screen.findByText('Insert Your Ballot Below');
// Close the polls
fetchMock.post('/precinct-scanner/export', GENERAL_ALL_PRECINCTS_CVRS);
- const pollWorkerCard = makePollWorkerCard(
- electionSample2Definition.electionHash
- );
+ const pollWorkerCard = makePollWorkerCard(electionDefinition.electionHash);
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to close the polls?');
- mockPollsChange('polls_closed_final');
+ apiMock.expectSetPollsState('polls_closed_final');
+ apiMock.expectGetConfig({
+ electionDefinition,
+ pollsState: 'polls_closed_final',
+ });
userEvent.click(screen.getByText('Yes, Close the Polls'));
await screen.findByText('Polls are closed.');
@@ -960,27 +959,24 @@ test('printing: polls closed, general election, all precincts', async () => {
});
test('saving to card: polls closed, general election, all precincts', async () => {
- const { election } = electionSample2Definition;
- const { mockPollsChange } = mockConfig({
- electionDefinition: electionSample2Definition,
- pollsState: 'polls_open',
- });
- fetchMock.get('/precinct-scanner/scanner/status', {
- body: { ...statusNoPaper, ballotsCounted: 2 },
- });
- const { card, renderApp } = buildApp();
+ const electionDefinition = electionSample2Definition;
+ const { election } = electionDefinition;
+ apiMock.expectGetConfig({ electionDefinition, pollsState: 'polls_open' });
+ apiMock.expectGetScannerStatus({ ...statusNoPaper, ballotsCounted: 2 }, 4);
+ const { card } = renderApp({ connectPrinter: false });
const writeLongObjectMock = jest.spyOn(card, 'writeLongObject');
- renderApp();
await screen.findByText('Insert Your Ballot Below');
// Close the polls
fetchMock.post('/precinct-scanner/export', GENERAL_ALL_PRECINCTS_CVRS);
- const pollWorkerCard = makePollWorkerCard(
- electionSample2Definition.electionHash
- );
+ const pollWorkerCard = makePollWorkerCard(electionDefinition.electionHash);
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to close the polls?');
- mockPollsChange('polls_closed_final');
+ apiMock.expectSetPollsState('polls_closed_final');
+ apiMock.expectGetConfig({
+ electionDefinition,
+ pollsState: 'polls_closed_final',
+ });
userEvent.click(await screen.findByText('Yes, Close the Polls'));
await screen.findByText('Polls are closed.');
card.removeCard();
@@ -1053,26 +1049,27 @@ const GENERAL_SINGLE_PRECINCT_CVRS = generateFileContentFromCvrs([
]);
test('printing: polls closed, general election, single precinct', async () => {
- const { mockPollsChange } = mockConfig({
- electionDefinition: electionSample2Definition,
+ const electionDefinition = electionSample2Definition;
+ apiMock.expectGetConfig({
+ electionDefinition,
precinctSelection: singlePrecinctSelectionFor('23'),
pollsState: 'polls_open',
});
- fetchMock.get('/precinct-scanner/scanner/status', {
- body: { ...statusNoPaper, ballotsCounted: 2 },
- });
- const { card, renderApp } = buildApp(true);
- renderApp();
+ apiMock.expectGetScannerStatus({ ...statusNoPaper, ballotsCounted: 2 }, 3);
+ const { card } = renderApp({ connectPrinter: true });
await screen.findByText('Insert Your Ballot Below');
// Close the polls
fetchMock.post('/precinct-scanner/export', GENERAL_SINGLE_PRECINCT_CVRS);
- const pollWorkerCard = makePollWorkerCard(
- electionSample2Definition.electionHash
- );
+ const pollWorkerCard = makePollWorkerCard(electionDefinition.electionHash);
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to close the polls?');
- mockPollsChange('polls_closed_final');
+ apiMock.expectSetPollsState('polls_closed_final');
+ apiMock.expectGetConfig({
+ electionDefinition,
+ precinctSelection: singlePrecinctSelectionFor('23'),
+ pollsState: 'polls_closed_final',
+ });
userEvent.click(await screen.findByText('Yes, Close the Polls'));
await screen.findByText('Polls are closed.');
@@ -1122,28 +1119,29 @@ test('printing: polls closed, general election, single precinct', async () => {
});
test('saving to card: polls closed, general election, single precinct', async () => {
- const { election } = electionSample2Definition;
- const { mockPollsChange } = mockConfig({
- electionDefinition: electionSample2Definition,
+ const electionDefinition = electionSample2Definition;
+ const { election } = electionDefinition;
+ apiMock.expectGetConfig({
+ electionDefinition,
precinctSelection: singlePrecinctSelectionFor('23'),
pollsState: 'polls_open',
});
- fetchMock.get('/precinct-scanner/scanner/status', {
- body: { ...statusNoPaper, ballotsCounted: 2 },
- });
- const { card, renderApp } = buildApp();
+ apiMock.expectGetScannerStatus({ ...statusNoPaper, ballotsCounted: 2 }, 4);
+ const { card } = renderApp({ connectPrinter: false });
const writeLongObjectMock = jest.spyOn(card, 'writeLongObject');
- renderApp();
await screen.findByText('Insert Your Ballot Below');
// Close the polls
fetchMock.post('/precinct-scanner/export', GENERAL_SINGLE_PRECINCT_CVRS);
- const pollWorkerCard = makePollWorkerCard(
- electionSample2Definition.electionHash
- );
+ const pollWorkerCard = makePollWorkerCard(electionDefinition.electionHash);
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to close the polls?');
- mockPollsChange('polls_closed_final');
+ apiMock.expectSetPollsState('polls_closed_final');
+ apiMock.expectGetConfig({
+ electionDefinition,
+ precinctSelection: singlePrecinctSelectionFor('23'),
+ pollsState: 'polls_closed_final',
+ });
userEvent.click(await screen.findByText('Yes, Close the Polls'));
await screen.findByText('Polls are closed.');
card.removeCard();
@@ -1180,27 +1178,28 @@ test('saving to card: polls closed, general election, single precinct', async ()
test('printing: polls paused', async () => {
MockDate.set('2022-10-31T16:23:00.000Z');
- const { mockPollsChange } = mockConfig({
- electionDefinition: electionSample2Definition,
+ const electionDefinition = electionSample2Definition;
+ apiMock.expectGetConfig({
+ electionDefinition,
precinctSelection: singlePrecinctSelectionFor('23'),
pollsState: 'polls_open',
});
- fetchMock.get('/precinct-scanner/scanner/status', {
- body: { ...statusNoPaper, ballotsCounted: 2 },
- });
- const { card, renderApp } = buildApp(true);
- renderApp();
+ apiMock.expectGetScannerStatus({ ...statusNoPaper, ballotsCounted: 2 }, 2);
+ const { card } = renderApp({ connectPrinter: true });
await screen.findByText('Insert Your Ballot Below');
// Close the polls
fetchMock.post('/precinct-scanner/export', GENERAL_SINGLE_PRECINCT_CVRS);
- const pollWorkerCard = makePollWorkerCard(
- electionSample2Definition.electionHash
- );
+ const pollWorkerCard = makePollWorkerCard(electionDefinition.electionHash);
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to close the polls?');
userEvent.click(await screen.findByText('No'));
- mockPollsChange('polls_paused');
+ apiMock.expectSetPollsState('polls_paused');
+ apiMock.expectGetConfig({
+ electionDefinition,
+ precinctSelection: singlePrecinctSelectionFor('23'),
+ pollsState: 'polls_paused',
+ });
userEvent.click(await screen.findByText('Pause Voting'));
await screen.findByText('Voting paused.');
@@ -1220,28 +1219,29 @@ test('printing: polls paused', async () => {
});
test('saving to card: polls paused', async () => {
- const { mockPollsChange } = mockConfig({
- electionDefinition: electionSample2Definition,
+ const electionDefinition = electionSample2Definition;
+ apiMock.expectGetConfig({
+ electionDefinition,
precinctSelection: singlePrecinctSelectionFor('23'),
pollsState: 'polls_open',
});
- fetchMock.get('/precinct-scanner/scanner/status', {
- body: { ...statusNoPaper, ballotsCounted: 2 },
- });
- const { card, renderApp } = buildApp();
+ apiMock.expectGetScannerStatus({ ...statusNoPaper, ballotsCounted: 2 }, 3);
+ const { card } = renderApp({ connectPrinter: false });
const writeLongObjectMock = jest.spyOn(card, 'writeLongObject');
- renderApp();
await screen.findByText('Insert Your Ballot Below');
// Pause the polls
fetchMock.post('/precinct-scanner/export', GENERAL_SINGLE_PRECINCT_CVRS);
- const pollWorkerCard = makePollWorkerCard(
- electionSample2Definition.electionHash
- );
+ const pollWorkerCard = makePollWorkerCard(electionDefinition.electionHash);
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to close the polls?');
userEvent.click(screen.getByText('No'));
- mockPollsChange('polls_paused');
+ apiMock.expectSetPollsState('polls_paused');
+ apiMock.expectGetConfig({
+ electionDefinition,
+ precinctSelection: singlePrecinctSelectionFor('23'),
+ pollsState: 'polls_paused',
+ });
userEvent.click(await screen.findByText('Pause Voting'));
await screen.findByText('Voting paused.');
card.removeCard();
@@ -1264,27 +1264,28 @@ test('saving to card: polls paused', async () => {
test('printing: polls unpaused', async () => {
MockDate.set('2022-10-31T16:23:00.000Z');
- const { mockPollsChange } = mockConfig({
- electionDefinition: electionSample2Definition,
+ const electionDefinition = electionSample2Definition;
+ apiMock.expectGetConfig({
+ electionDefinition,
precinctSelection: singlePrecinctSelectionFor('23'),
pollsState: 'polls_paused',
});
- fetchMock.get('/precinct-scanner/scanner/status', {
- body: { ...statusNoPaper, ballotsCounted: 2 },
- });
- const { card, renderApp } = buildApp(true);
- renderApp();
+ apiMock.expectGetScannerStatus({ ...statusNoPaper, ballotsCounted: 2 }, 2);
+ const { card } = renderApp({ connectPrinter: true });
await screen.findByText('Polls Paused');
// Unpause the polls
fetchMock.post('/precinct-scanner/export', GENERAL_SINGLE_PRECINCT_CVRS);
- const pollWorkerCard = makePollWorkerCard(
- electionSample2Definition.electionHash
- );
+ const pollWorkerCard = makePollWorkerCard(electionDefinition.electionHash);
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to resume voting?');
userEvent.click(await screen.findByText('Yes, Resume Voting'));
- mockPollsChange('polls_open');
+ apiMock.expectSetPollsState('polls_open');
+ apiMock.expectGetConfig({
+ electionDefinition,
+ precinctSelection: singlePrecinctSelectionFor('23'),
+ pollsState: 'polls_open',
+ });
await screen.findByText('Voting resumed.');
await expectPrint((printedElement) => {
@@ -1303,27 +1304,28 @@ test('printing: polls unpaused', async () => {
});
test('saving to card: polls unpaused', async () => {
- const { mockPollsChange } = mockConfig({
- electionDefinition: electionSample2Definition,
+ const electionDefinition = electionSample2Definition;
+ apiMock.expectGetConfig({
+ electionDefinition,
precinctSelection: singlePrecinctSelectionFor('23'),
pollsState: 'polls_paused',
});
- fetchMock.get('/precinct-scanner/scanner/status', {
- body: { ...statusNoPaper, ballotsCounted: 2 },
- });
- const { card, renderApp } = buildApp();
+ apiMock.expectGetScannerStatus({ ...statusNoPaper, ballotsCounted: 2 }, 3);
+ const { card } = renderApp({ connectPrinter: false });
const writeLongObjectMock = jest.spyOn(card, 'writeLongObject');
- renderApp();
// Unpause the polls
fetchMock.post('/precinct-scanner/export', GENERAL_SINGLE_PRECINCT_CVRS);
- const pollWorkerCard = makePollWorkerCard(
- electionSample2Definition.electionHash
- );
+ const pollWorkerCard = makePollWorkerCard(electionDefinition.electionHash);
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to resume voting?');
userEvent.click(screen.getByText('Yes, Resume Voting'));
- mockPollsChange('polls_open');
+ apiMock.expectSetPollsState('polls_open');
+ apiMock.expectGetConfig({
+ electionDefinition,
+ precinctSelection: singlePrecinctSelectionFor('23'),
+ pollsState: 'polls_open',
+ });
await screen.findByText('Voting resumed.');
card.removeCard();
await advanceTimersAndPromises(1);
@@ -1344,27 +1346,28 @@ test('saving to card: polls unpaused', async () => {
});
test('printing: polls closed from paused, general election, single precinct', async () => {
- const { mockPollsChange } = mockConfig({
- electionDefinition: electionSample2Definition,
+ const electionDefinition = electionSample2Definition;
+ apiMock.expectGetConfig({
+ electionDefinition,
precinctSelection: singlePrecinctSelectionFor('23'),
pollsState: 'polls_paused',
});
- fetchMock.get('/precinct-scanner/scanner/status', {
- body: { ...statusNoPaper, ballotsCounted: 2 },
- });
- const { card, renderApp } = buildApp(true);
- renderApp();
+ apiMock.expectGetScannerStatus({ ...statusNoPaper, ballotsCounted: 2 }, 3);
+ const { card } = renderApp({ connectPrinter: true });
await screen.findByText('Polls Paused');
// Close the polls
fetchMock.post('/precinct-scanner/export', GENERAL_SINGLE_PRECINCT_CVRS);
- const pollWorkerCard = makePollWorkerCard(
- electionSample2Definition.electionHash
- );
+ const pollWorkerCard = makePollWorkerCard(electionDefinition.electionHash);
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to resume voting?');
userEvent.click(screen.getByText('No'));
- mockPollsChange('polls_closed_final');
+ apiMock.expectSetPollsState('polls_closed_final');
+ apiMock.expectGetConfig({
+ electionDefinition,
+ precinctSelection: singlePrecinctSelectionFor('23'),
+ pollsState: 'polls_closed_final',
+ });
userEvent.click(await screen.findByText('Close Polls'));
await screen.findByText('Polls are closed.');
@@ -1414,29 +1417,30 @@ test('printing: polls closed from paused, general election, single precinct', as
});
test('saving to card: polls closed from paused, general election, single precinct', async () => {
- const { election } = electionSample2Definition;
- const { mockPollsChange } = mockConfig({
- electionDefinition: electionSample2Definition,
+ const electionDefinition = electionSample2Definition;
+ const { election } = electionDefinition;
+ apiMock.expectGetConfig({
+ electionDefinition,
precinctSelection: singlePrecinctSelectionFor('23'),
pollsState: 'polls_paused',
});
- fetchMock.get('/precinct-scanner/scanner/status', {
- body: { ...statusNoPaper, ballotsCounted: 2 },
- });
- const { card, renderApp } = buildApp();
+ apiMock.expectGetScannerStatus({ ...statusNoPaper, ballotsCounted: 2 }, 4);
+ const { card } = renderApp({ connectPrinter: false });
const writeLongObjectMock = jest.spyOn(card, 'writeLongObject');
- renderApp();
await screen.findByText('Polls Paused');
// Close the polls
fetchMock.post('/precinct-scanner/export', GENERAL_SINGLE_PRECINCT_CVRS);
- const pollWorkerCard = makePollWorkerCard(
- electionSample2Definition.electionHash
- );
+ const pollWorkerCard = makePollWorkerCard(electionDefinition.electionHash);
card.insertCard(pollWorkerCard);
await screen.findByText('Do you want to resume voting?');
userEvent.click(screen.getByText('No'));
- mockPollsChange('polls_closed_final');
+ apiMock.expectSetPollsState('polls_closed_final');
+ apiMock.expectGetConfig({
+ electionDefinition,
+ precinctSelection: singlePrecinctSelectionFor('23'),
+ pollsState: 'polls_closed_final',
+ });
userEvent.click(await screen.findByText('Close Polls'));
await screen.findByText('Polls are closed.');
card.removeCard();
diff --git a/apps/vx-scan/frontend/src/app_unhappy_paths.test.tsx b/apps/vx-scan/frontend/src/app_unhappy_paths.test.tsx
index ad4c4aac0..0438da246 100644
--- a/apps/vx-scan/frontend/src/app_unhappy_paths.test.tsx
+++ b/apps/vx-scan/frontend/src/app_unhappy_paths.test.tsx
@@ -1,4 +1,5 @@
-import { act, screen, waitFor } from '@testing-library/react';
+import React from 'react';
+import { act, render, screen, waitFor } from '@testing-library/react';
import {
electionSampleDefinition,
electionWithMsEitherNeitherDefinition,
@@ -11,67 +12,93 @@ import {
makePollWorkerCard,
makeVoterCard,
} from '@votingworks/test-utils';
-import { Scan } from '@votingworks/api';
import fetchMock from 'fetch-mock';
-import { deferred } from '@votingworks/utils';
+import {
+ deferred,
+ MemoryCard,
+ MemoryHardware,
+ MemoryStorage,
+} from '@votingworks/utils';
import userEvent from '@testing-library/user-event';
+import { ServerError } from '@votingworks/grout';
+import { fakeLogger } from '@votingworks/logging';
import { MachineConfigResponse } from './config/types';
import {
authenticateElectionManagerCard,
scannerStatus,
} from '../test/helpers/helpers';
-import { buildApp } from '../test/helpers/build_app';
-import { mockConfig } from '../test/helpers/mock_config';
+import { createApiMock, statusNoPaper } from '../test/helpers/mock_api_client';
+import { App, AppProps } from './app';
const getMachineConfigBody: MachineConfigResponse = {
machineId: '0002',
codeVersion: '3.14',
};
-const statusNoPaper: Scan.GetPrecinctScannerStatusResponse = {
- state: 'no_paper',
- canUnconfigure: true,
- ballotsCounted: 0,
-};
+const apiMock = createApiMock();
+
+function renderApp(props: Partial = {}) {
+ const card = new MemoryCard();
+ const hardware = MemoryHardware.build({
+ connectPrinter: false,
+ connectCardReader: true,
+ connectPrecinctScanner: true,
+ });
+ const logger = fakeLogger();
+ const storage = new MemoryStorage();
+ render(
+
+ );
+ return { card, hardware, logger, storage };
+}
beforeEach(() => {
jest.useFakeTimers();
fetchMock.reset();
fetchMock.get('/machine-config', { body: getMachineConfigBody });
+ apiMock.mockApiClient.reset();
+});
+
+afterEach(() => {
+ apiMock.mockApiClient.assertComplete();
});
-test('when services/scan does not respond shows loading screen', async () => {
- fetchMock
- .get('/precinct-scanner/config', { status: 404 })
- .get('/precinct-scanner/scanner/status', { status: 404 });
+test('when backend does not respond shows loading screen', async () => {
+ apiMock.mockApiClient.getConfig
+ .expectCallWith()
+ .throws(new ServerError('not responding'));
+ apiMock.expectGetScannerStatus(statusNoPaper);
+ apiMock.expectGetConfig({ electionDefinition: undefined });
- buildApp().renderApp();
+ renderApp();
await screen.findByText('Loading Configuration…');
+ jest.advanceTimersByTime(1000);
+ await screen.findByText('VxScan is Not Configured');
});
-test('services/scan fails to unconfigure', async () => {
- mockConfig();
- fetchMock
- .get('/precinct-scanner/scanner/status', statusNoPaper)
- .deleteOnce('/precinct-scanner/config/election', { status: 404 });
+test('backend fails to unconfigure', async () => {
+ apiMock.expectGetConfig();
+ apiMock.expectGetScannerStatus({ ...statusNoPaper, canUnconfigure: true });
+ apiMock.mockApiClient.unconfigureElection
+ .expectCallWith({})
+ .throws(new ServerError('failed'));
- const { renderApp, card } = buildApp();
- renderApp();
+ const { card } = renderApp();
const electionManagerCard = makeElectionManagerCard(
electionSampleDefinition.electionHash,
'123456'
);
card.insertCard(electionManagerCard, electionSampleDefinition.electionData);
- await screen.findByText('Enter the card security code to unlock.');
- userEvent.click(screen.getByText('1'));
- userEvent.click(screen.getByText('2'));
- userEvent.click(screen.getByText('3'));
- userEvent.click(screen.getByText('4'));
- userEvent.click(screen.getByText('5'));
- userEvent.click(screen.getByText('6'));
- await screen.findByText('Election Manager Settings');
+ await authenticateElectionManagerCard();
userEvent.click(
await screen.findByText('Delete All Election Data from VxScan')
@@ -82,11 +109,10 @@ test('services/scan fails to unconfigure', async () => {
});
test('Show invalid card screen when unsupported cards are given', async () => {
- mockConfig();
- fetchMock.get('/precinct-scanner/scanner/status', statusNoPaper);
+ apiMock.expectGetConfig();
+ apiMock.expectGetScannerStatus(statusNoPaper, 2);
- const { renderApp, card } = buildApp();
- renderApp();
+ const { card } = renderApp();
await screen.findByText('Polls Closed');
const voterCard = makeVoterCard(electionSampleDefinition.election);
card.insertCard(voterCard);
@@ -112,19 +138,23 @@ test('Show invalid card screen when unsupported cards are given', async () => {
card.removeCard();
await screen.findByText('Polls Closed');
+ // Insert a poll worker card which is invalid
const pollWorkerCardWrongElection = makePollWorkerCard(
electionWithMsEitherNeitherDefinition.electionHash
);
card.insertCard(pollWorkerCardWrongElection);
await screen.findByText('Invalid Card');
+
+ // Remove card
+ card.removeCard();
+ await screen.findByText('Polls Closed');
});
test('show card backwards screen when card connection error occurs', async () => {
- mockConfig();
- fetchMock.get('/precinct-scanner/scanner/status', statusNoPaper);
+ apiMock.expectGetConfig();
+ apiMock.expectGetScannerStatus(statusNoPaper);
+ const { card } = renderApp();
- const { renderApp, card } = buildApp();
- renderApp();
await screen.findByText('Polls Closed');
card.insertCard(undefined, undefined, 'error');
await screen.findByText('Card is Backwards');
@@ -135,69 +165,61 @@ test('show card backwards screen when card connection error occurs', async () =>
});
test('shows internal wiring message when there is no plustek scanner, but tablet is plugged in', async () => {
- mockConfig();
- const { renderApp, hardware } = buildApp();
+ apiMock.expectGetConfig();
+ const hardware = MemoryHardware.buildStandard();
hardware.setPrecinctScannerConnected(false);
hardware.setBatteryDischarging(false);
- fetchMock.get('/precinct-scanner/scanner/status', {
+ apiMock.expectGetScannerStatus({
...statusNoPaper,
state: 'disconnected',
});
- renderApp();
+ renderApp({ hardware });
await screen.findByRole('heading', { name: 'Internal Connection Problem' });
screen.getByText('Please ask a poll worker for help.');
});
test('shows power cable message when there is no plustek scanner and tablet is not plugged in', async () => {
- mockConfig();
- const { renderApp, hardware } = buildApp();
+ apiMock.expectGetConfig();
+ const hardware = MemoryHardware.buildStandard();
hardware.setPrecinctScannerConnected(false);
hardware.setBatteryDischarging(true);
- fetchMock.get('/precinct-scanner/scanner/status', {
+ apiMock.expectGetScannerStatus({
...statusNoPaper,
state: 'disconnected',
});
- renderApp();
+ renderApp({ hardware });
await screen.findByRole('heading', { name: 'No Power Detected' });
screen.getByText('Please ask a poll worker to plug in the power cord.');
- fetchMock.get(
- '/precinct-scanner/scanner/status',
- { body: statusNoPaper },
- { overwriteRoutes: true }
- );
+ apiMock.expectGetScannerStatus(statusNoPaper);
act(() => hardware.setPrecinctScannerConnected(true));
await screen.findByRole('heading', { name: 'Polls Closed' });
- await waitFor(() =>
- expect(fetchMock.lastUrl()).toEqual('/precinct-scanner/scanner/status')
- );
- expect(fetchMock.done()).toBe(true);
});
test('shows instructions to restart when the plustek crashed', async () => {
- mockConfig({ pollsState: 'polls_open' });
- const { renderApp, hardware } = buildApp();
+ apiMock.expectGetConfig({ pollsState: 'polls_open' });
+ const hardware = MemoryHardware.buildStandard();
hardware.setPrecinctScannerConnected(false);
- fetchMock.get('/precinct-scanner/scanner/status', {
+ apiMock.expectGetScannerStatus({
...statusNoPaper,
state: 'unrecoverable_error',
});
- renderApp();
+ renderApp({ hardware });
await screen.findByRole('heading', { name: 'Ballot Not Counted' });
screen.getByText('Ask a poll worker to restart the scanner.');
- expect(fetchMock.done()).toBe(true);
});
test('App shows warning message to connect to power when disconnected', async () => {
- const { mockPollsChange } = mockConfig();
- const { renderApp, hardware, card } = buildApp();
+ apiMock.expectGetConfig();
+ const hardware = MemoryHardware.buildStandard();
+ hardware.setPrinterConnected(false);
hardware.setBatteryDischarging(true);
hardware.setBatteryLevel(0.9);
const kiosk = fakeKiosk();
kiosk.getUsbDrives = jest.fn().mockResolvedValue([fakeUsbDrive()]);
window.kiosk = kiosk;
- fetchMock.get('/precinct-scanner/scanner/status', { body: statusNoPaper });
- renderApp();
+ apiMock.expectGetScannerStatus(statusNoPaper, 3);
+ const { card } = renderApp({ hardware });
fetchMock.post('/precinct-scanner/export', {});
await screen.findByText('Polls Closed');
await screen.findByText('No Power Detected.');
@@ -218,13 +240,15 @@ test('App shows warning message to connect to power when disconnected', async ()
electionSampleDefinition.electionHash
);
card.insertCard(pollWorkerCard);
- mockPollsChange('polls_open');
+ apiMock.expectSetPollsState('polls_open');
+ apiMock.expectGetConfig({ pollsState: 'polls_open' });
userEvent.click(await screen.findByText('Yes, Open the Polls'));
await screen.findByText('Polls are open.');
// Remove pollworker card
card.removeCard();
await screen.findByText('Insert Your Ballot Below');
+ return;
// There should be no warning about power
expect(screen.queryByText('No Power Detected.')).toBeNull();
// Disconnect from power and check for warning
@@ -235,16 +259,13 @@ test('App shows warning message to connect to power when disconnected', async ()
});
test('removing card during calibration', async () => {
- const { mockPollsChange } = mockConfig();
-
- const { renderApp, card } = buildApp();
+ apiMock.expectGetConfig();
const kiosk = fakeKiosk();
kiosk.getUsbDrives = jest.fn().mockResolvedValue([fakeUsbDrive()]);
window.kiosk = kiosk;
- fetchMock
- .get('/precinct-scanner/scanner/status', { body: statusNoPaper })
- .post('/precinct-scanner/export', {});
- renderApp();
+ apiMock.expectGetScannerStatus(statusNoPaper, 4);
+ fetchMock.post('/precinct-scanner/export', {});
+ const { card } = renderApp();
// Open Polls
const pollWorkerCard = makePollWorkerCard(
@@ -254,7 +275,8 @@ test('removing card during calibration', async () => {
userEvent.click(
await screen.findByRole('button', { name: 'Yes, Open the Polls' })
);
- mockPollsChange('polls_open');
+ apiMock.expectSetPollsState('polls_open');
+ apiMock.expectGetConfig({ pollsState: 'polls_open' });
await screen.findByText('Polls are open.');
card.removeCard();
await screen.findByText('Insert Your Ballot Below');
@@ -266,36 +288,22 @@ test('removing card during calibration', async () => {
card.insertCard(electionManagerCard, electionSampleDefinition.electionData);
await authenticateElectionManagerCard();
- const { promise, resolve } = deferred();
- fetchMock.post('/precinct-scanner/scanner/calibrate', promise);
+ const { promise, resolve } = deferred();
+ apiMock.mockApiClient.calibrate.expectCallWith().returns(promise);
userEvent.click(
await screen.findByRole('button', { name: 'Calibrate Scanner' })
);
await screen.findByText('Waiting for Paper');
- fetchMock.getOnce(
- '/precinct-scanner/scanner/status',
- { body: scannerStatus({ state: 'ready_to_scan' }) },
- { overwriteRoutes: true }
- );
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'ready_to_scan' }));
userEvent.click(await screen.findByRole('button', { name: 'Calibrate' }));
- expect(fetchMock.calls('/precinct-scanner/scanner/calibrate')).toHaveLength(
- 1
- );
await screen.findByText(/Calibrating/);
- fetchMock.get(
- '/precinct-scanner/scanner/status',
- { body: scannerStatus({ state: 'calibrating' }) },
- { overwriteRoutes: true }
- );
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'calibrating' }));
// Wait for status to update to calibrating (no way to tell on screen)
- const statusCallCount = fetchMock.calls(
- '/precinct-scanner/scanner/status'
- ).length;
await waitFor(() =>
- expect(
- fetchMock.calls('/precinct-scanner/scanner/status').length
- ).toBeGreaterThan(statusCallCount)
+ expect(() =>
+ apiMock.mockApiClient.getScannerStatus.assertComplete()
+ ).not.toThrow()
);
// Removing card shouldn't crash the app - for now we just show a blank screen
@@ -304,13 +312,7 @@ test('removing card during calibration', async () => {
expect(screen.queryByText(/Calibrating/)).not.toBeInTheDocument();
});
- fetchMock.get(
- '/precinct-scanner/scanner/status',
- { body: statusNoPaper },
- { overwriteRoutes: true }
- );
- resolve({ body: { status: 'ok' } });
+ apiMock.expectGetScannerStatus(statusNoPaper);
+ resolve(true);
await screen.findByText('Insert Your Ballot Below');
-
- expect(fetchMock.done()).toBe(true);
});
diff --git a/apps/vx-scan/frontend/src/components/calibrate_scanner_modal.test.tsx b/apps/vx-scan/frontend/src/components/calibrate_scanner_modal.test.tsx
index e1e5a101b..687a74e67 100644
--- a/apps/vx-scan/frontend/src/components/calibrate_scanner_modal.test.tsx
+++ b/apps/vx-scan/frontend/src/components/calibrate_scanner_modal.test.tsx
@@ -1,10 +1,14 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { Scan } from '@votingworks/api';
-import fetchMock from 'fetch-mock';
import { deferred } from '@votingworks/utils';
import userEvent from '@testing-library/user-event';
-import { CalibrateScannerModal } from './calibrate_scanner_modal';
+import {
+ CalibrateScannerModal,
+ CalibrateScannerModalProps,
+} from './calibrate_scanner_modal';
+import { ApiClientContext } from '../api/api';
+import { createApiMock } from '../../test/helpers/mock_api_client';
const fakeScannerStatus: Scan.PrecinctScannerStatus = {
state: 'no_paper',
@@ -12,13 +16,30 @@ const fakeScannerStatus: Scan.PrecinctScannerStatus = {
canUnconfigure: true,
};
-test('shows instructions', () => {
- render(
-
+const apiMock = createApiMock();
+
+function renderModal(props: Partial = {}) {
+ return render(
+
+
+
);
+}
+
+beforeEach(() => {
+ apiMock.mockApiClient.reset();
+});
+
+afterEach(() => {
+ apiMock.mockApiClient.assertComplete();
+});
+
+test('shows instructions', () => {
+ renderModal();
screen.getByRole('heading', { name: 'Calibrate Scanner' });
screen.getByText(/blank sheet of white paper/);
@@ -26,12 +47,7 @@ test('shows instructions', () => {
test('waiting for paper', async () => {
const onCancel = jest.fn();
- render(
-
- );
+ renderModal({ onCancel });
expect(
(await screen.findByText('Waiting for Paper')).disabled
@@ -43,12 +59,10 @@ test('waiting for paper', async () => {
test('scanner not available', async () => {
const onCancel = jest.fn();
- render(
-
- );
+ renderModal({
+ scannerStatus: { ...fakeScannerStatus, state: 'jammed' },
+ onCancel,
+ });
expect(
(await screen.findByText('Cannot Calibrate')).disabled
@@ -59,53 +73,41 @@ test('scanner not available', async () => {
});
test('calibrate success', async () => {
- const { promise, resolve } = deferred<{ body: Scan.CalibrateResponse }>();
- fetchMock.postOnce('/precinct-scanner/scanner/calibrate', promise);
- render(
-
- );
+ const { promise, resolve } = deferred();
+ apiMock.mockApiClient.calibrate.expectCallWith().returns(promise);
+ renderModal({
+ // Note that in reality, scanner status would update as the scanner
+ // calibrates, but the modal ignores it, so we don't bother mocking the
+ // changes.
+ scannerStatus: { ...fakeScannerStatus, state: 'ready_to_scan' },
+ });
userEvent.click(await screen.findByText('Calibrate'));
- expect(fetchMock.done()).toBe(true);
screen.getByText('Calibrating…');
- resolve({ body: { status: 'ok' } });
+ resolve(true);
await screen.findByText('Calibration succeeded!');
});
test('calibrate error and cancel', async () => {
- const { promise, resolve } = deferred<{ body: Scan.CalibrateResponse }>();
- fetchMock.postOnce('/precinct-scanner/scanner/calibrate', promise);
+ const { promise, resolve } = deferred();
+ apiMock.mockApiClient.calibrate.expectCallWith().returns(promise);
const onCancel = jest.fn();
- render(
-
- );
+ renderModal({
+ // Note that in reality, scanner status would update as the scanner
+ // calibrates, but the modal ignores it, so we don't bother mocking the
+ // changes.
+ scannerStatus: { ...fakeScannerStatus, state: 'ready_to_scan' },
+ onCancel,
+ });
userEvent.click(await screen.findByText('Calibrate'));
- expect(fetchMock.done()).toBe(true);
screen.getByText('Calibrating…');
- resolve({
- body: {
- status: 'error',
- errors: [{ type: 'error', message: 'Calibration error' }],
- },
- });
+ resolve(false);
await screen.findByText('Calibration failed!');
@@ -114,29 +116,20 @@ test('calibrate error and cancel', async () => {
});
test('calibrate error and try again', async () => {
- const { promise, resolve } = deferred<{ body: Scan.CalibrateResponse }>();
- fetchMock.postOnce('/precinct-scanner/scanner/calibrate', promise);
- render(
-
- );
+ const { promise, resolve } = deferred();
+ apiMock.mockApiClient.calibrate.expectCallWith().returns(promise);
+ renderModal({
+ // Note that in reality, scanner status would update as the scanner
+ // calibrates, but the modal ignores it, so we don't bother mocking the
+ // changes.
+ scannerStatus: { ...fakeScannerStatus, state: 'ready_to_scan' },
+ });
userEvent.click(await screen.findByText('Calibrate'));
- expect(fetchMock.done()).toBe(true);
screen.getByText('Calibrating…');
- resolve({
- body: {
- status: 'error',
- errors: [{ type: 'error', message: 'Calibration error' }],
- },
- });
+ resolve(false);
await screen.findByText('Calibration failed!');
@@ -147,21 +140,17 @@ test('calibrate error and try again', async () => {
// This test won't actually fail, but it will cause the warning:
// "An update to CalibrateScannerModal inside a test was not wrapped in act(...)."
test('unmount during calibration (e.g. if election manager card removed)', async () => {
- const { promise, resolve } = deferred<{ body: Scan.CalibrateResponse }>();
- fetchMock.postOnce('/precinct-scanner/scanner/calibrate', promise);
- const { unmount } = render(
-
- );
+ const { promise, resolve } = deferred();
+ apiMock.mockApiClient.calibrate.expectCallWith().returns(promise);
+ const { unmount } = renderModal({
+ scannerStatus: { ...fakeScannerStatus, state: 'ready_to_scan' },
+ });
userEvent.click(await screen.findByText('Calibrate'));
- expect(fetchMock.done()).toBe(true);
screen.getByText('Calibrating…');
unmount();
- resolve({ body: { status: 'ok' } });
+ resolve(true);
});
diff --git a/apps/vx-scan/frontend/src/components/calibrate_scanner_modal.tsx b/apps/vx-scan/frontend/src/components/calibrate_scanner_modal.tsx
index d152d3983..435a20cb4 100644
--- a/apps/vx-scan/frontend/src/components/calibrate_scanner_modal.tsx
+++ b/apps/vx-scan/frontend/src/components/calibrate_scanner_modal.tsx
@@ -2,9 +2,9 @@ import React, { useState } from 'react';
import { Scan } from '@votingworks/api';
import { Button, Modal, Prose, useCancelablePromise } from '@votingworks/ui';
import { assert } from '@votingworks/utils';
-import * as scanner from '../api/scan';
+import { useApiClient } from '../api/api';
-export interface Props {
+export interface CalibrateScannerModalProps {
scannerStatus: Scan.PrecinctScannerStatus;
onCancel: VoidFunction;
}
@@ -18,14 +18,15 @@ type CalibrationState = 'ready' | 'calibrating' | 'calibrated' | 'failed';
export function CalibrateScannerModal({
scannerStatus,
onCancel,
-}: Props): JSX.Element {
+}: CalibrateScannerModalProps): JSX.Element {
+ const apiClient = useApiClient();
const [calibrationState, setCalibrationState] =
useState('ready');
const makeCancelable = useCancelablePromise();
async function calibrate() {
setCalibrationState('calibrating');
- const success = await makeCancelable(scanner.calibrate());
+ const success = await makeCancelable(apiClient.calibrate());
setCalibrationState(success ? 'calibrated' : 'failed');
}
diff --git a/apps/vx-scan/frontend/src/components/export_backup_modal.test.tsx b/apps/vx-scan/frontend/src/components/export_backup_modal.test.tsx
index d1e24e6c4..a689eb4d0 100644
--- a/apps/vx-scan/frontend/src/components/export_backup_modal.test.tsx
+++ b/apps/vx-scan/frontend/src/components/export_backup_modal.test.tsx
@@ -1,35 +1,53 @@
-import { fireEvent, screen, waitFor } from '@testing-library/react';
-import { Scan } from '@votingworks/api';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { fakeKiosk, fakeUsbDrive, Inserted } from '@votingworks/test-utils';
-import { typedAs, usbstick } from '@votingworks/utils';
-import fetchMock from 'fetch-mock';
+import { err, ok } from '@votingworks/types';
+import { usbstick } from '@votingworks/utils';
import jestFetchMock from 'jest-fetch-mock';
import React from 'react';
+import { createApiMock } from '../../test/helpers/mock_api_client';
import { renderInAppContext } from '../../test/helpers/render_in_app_context';
-import { ExportBackupModal } from './export_backup_modal';
+import { ApiClientContext } from '../api/api';
+import {
+ ExportBackupModal,
+ ExportBackupModalProps,
+} from './export_backup_modal';
const { UsbDriveStatus } = usbstick;
const auth = Inserted.fakeElectionManagerAuth();
+const apiMock = createApiMock();
+
beforeEach(() => {
jestFetchMock.enableMocks();
- fetchMock.reset();
- fetchMock.mock();
+ apiMock.mockApiClient.reset();
+});
+
+afterEach(() => {
+ apiMock.mockApiClient.assertComplete();
});
+function renderModal(props: Partial = {}) {
+ return renderInAppContext(
+
+
+ ,
+ { auth }
+ );
+}
+
test('renders loading screen when USB drive is mounting or ejecting in export modal', () => {
const usbStatuses = [UsbDriveStatus.present, UsbDriveStatus.ejecting];
for (const status of usbStatuses) {
- const closeFn = jest.fn();
- const { unmount } = renderInAppContext(
- ,
- { auth }
- );
+ const { unmount } = renderModal({
+ usbDrive: { status, eject: jest.fn() },
+ });
screen.getByText('Loading');
unmount();
}
@@ -43,20 +61,17 @@ test('render no USB found screen when there is not a mounted USB drive', () => {
];
for (const status of usbStatuses) {
- const closeFn = jest.fn();
- const { unmount } = renderInAppContext(
- ,
- { auth }
- );
+ const onClose = jest.fn();
+ const { unmount } = renderModal({
+ onClose,
+ usbDrive: { status, eject: jest.fn() },
+ });
screen.getByText('No USB Drive Detected');
screen.getByText('Please insert a USB drive to save the backup.');
screen.getByAltText('Insert USB Image');
- fireEvent.click(screen.getByText('Cancel'));
- expect(closeFn).toHaveBeenCalled();
+ userEvent.click(screen.getByText('Cancel'));
+ expect(onClose).toHaveBeenCalled();
unmount();
}
@@ -67,82 +82,63 @@ test('render export modal when a USB drive is mounted as expected and allows aut
window.kiosk = mockKiosk;
mockKiosk.getUsbDrives.mockResolvedValue([fakeUsbDrive()]);
- const closeFn = jest.fn();
+ const onClose = jest.fn();
const ejectFn = jest.fn();
- renderInAppContext(
- ,
- { auth }
- );
+ renderModal({
+ onClose,
+ usbDrive: { status: UsbDriveStatus.mounted, eject: ejectFn },
+ });
screen.getByText('Save Backup');
- fetchMock.postOnce('/precinct-scanner/backup-to-usb-drive', {
- status: 200,
- body: typedAs({
- status: 'ok',
- paths: ['/media/usb-drive-sdb1/backup.zip'],
- }),
- });
- fireEvent.click(screen.getByText('Save'));
+ apiMock.mockApiClient.backupToUsbDrive.expectCallWith().resolves(ok());
+ userEvent.click(screen.getByText('Save'));
await screen.findByText('Backup Saved');
- fireEvent.click(screen.getByText('Eject USB'));
+ userEvent.click(screen.getByText('Eject USB'));
expect(ejectFn).toHaveBeenCalled();
- fireEvent.click(screen.getByText('Cancel'));
- expect(closeFn).toHaveBeenCalled();
+ userEvent.click(screen.getByText('Cancel'));
+ expect(onClose).toHaveBeenCalled();
});
test('handles no USB drives', async () => {
const mockKiosk = fakeKiosk();
window.kiosk = mockKiosk;
- const closeFn = jest.fn();
- const { getByText } = renderInAppContext(
- ,
- { auth }
- );
- getByText('Save Backup');
+ const onClose = jest.fn();
+ renderModal({
+ onClose,
+ usbDrive: { status: UsbDriveStatus.mounted, eject: jest.fn() },
+ });
+ screen.getByText('Save Backup');
mockKiosk.getUsbDrives.mockResolvedValueOnce([]);
- fireEvent.click(getByText('Save'));
- await waitFor(() => getByText('Failed to Save Backup'));
- getByText(/No USB drive found./);
+ userEvent.click(screen.getByText('Save'));
+ await screen.findByText('Failed to Save Backup');
+ screen.getByText(/No USB drive found./);
- fireEvent.click(getByText('Close'));
- expect(closeFn).toHaveBeenCalled();
+ userEvent.click(screen.getByText('Close'));
+ expect(onClose).toHaveBeenCalled();
});
test('shows errors from the backend', async () => {
const mockKiosk = fakeKiosk();
window.kiosk = mockKiosk;
- const closeFn = jest.fn();
- renderInAppContext(
- ,
- { auth }
- );
+ const onClose = jest.fn();
+ renderModal({
+ onClose,
+ usbDrive: { status: UsbDriveStatus.mounted, eject: jest.fn() },
+ });
screen.getByText('Save Backup');
mockKiosk.getUsbDrives.mockResolvedValueOnce([fakeUsbDrive()]);
- fetchMock.postOnce('/precinct-scanner/backup-to-usb-drive', {
- status: 200,
- body: typedAs({
- status: 'error',
- errors: [{ type: 'permission-denied', message: 'Permission denied' }],
- }),
- });
- fireEvent.click(screen.getByText('Save'));
+ apiMock.mockApiClient.backupToUsbDrive
+ .expectCallWith()
+ .resolves(err({ type: 'permission-denied', message: 'Permission denied' }));
+ userEvent.click(screen.getByText('Save'));
await screen.findByText('Failed to Save Backup');
screen.getByText('Permission denied');
- fireEvent.click(screen.getByText('Close'));
- expect(closeFn).toHaveBeenCalled();
+ userEvent.click(screen.getByText('Close'));
+ expect(onClose).toHaveBeenCalled();
});
diff --git a/apps/vx-scan/frontend/src/components/export_backup_modal.tsx b/apps/vx-scan/frontend/src/components/export_backup_modal.tsx
index 523be2f0c..407dda14d 100644
--- a/apps/vx-scan/frontend/src/components/export_backup_modal.tsx
+++ b/apps/vx-scan/frontend/src/components/export_backup_modal.tsx
@@ -1,5 +1,3 @@
-import { Scan } from '@votingworks/api';
-import { safeParse } from '@votingworks/types';
import {
Button,
isElectionManagerAuth,
@@ -14,13 +12,14 @@ import { assert, throwIllegalValue, usbstick } from '@votingworks/utils';
import React, { useCallback, useContext, useState } from 'react';
import styled from 'styled-components';
import { AppContext } from '../contexts/app_context';
+import { useApiClient } from '../api/api';
const UsbImage = styled.img`
margin: 0 auto;
height: 200px;
`;
-export interface Props {
+export interface ExportBackupModalProps {
onClose: () => void;
usbDrive: UsbDrive;
}
@@ -34,7 +33,11 @@ enum ModalState {
const DEFAULT_ERROR = 'Failed to save backup.';
-export function ExportBackupModal({ onClose, usbDrive }: Props): JSX.Element {
+export function ExportBackupModal({
+ onClose,
+ usbDrive,
+}: ExportBackupModalProps): JSX.Element {
+ const apiClient = useApiClient();
const [currentState, setCurrentState] = useState(ModalState.INIT);
const [errorMessage, setErrorMessage] = useState('');
@@ -52,33 +55,20 @@ export function ExportBackupModal({ onClose, usbDrive }: Props): JSX.Element {
setCurrentState(ModalState.ERROR);
return;
}
- const httpResponse = await fetch('/precinct-scanner/backup-to-usb-drive', {
- method: 'POST',
- });
- if (!httpResponse.ok) {
- setErrorMessage(DEFAULT_ERROR);
- setCurrentState(ModalState.ERROR);
- return;
- }
-
- const body = await httpResponse.json();
- const result = safeParse(Scan.BackupToUsbResponseSchema, body);
-
- if (result.isErr()) {
- setErrorMessage(DEFAULT_ERROR);
- setCurrentState(ModalState.ERROR);
- } else {
- const response = result.ok();
-
- if (response.status === 'ok') {
- setCurrentState(ModalState.DONE);
- } else {
- setErrorMessage(response.errors[0].message ?? DEFAULT_ERROR);
+ try {
+ const result = await apiClient.backupToUsbDrive();
+ if (result.isErr()) {
+ setErrorMessage(result.err().message ?? DEFAULT_ERROR);
setCurrentState(ModalState.ERROR);
+ } else {
+ setCurrentState(ModalState.DONE);
}
+ } catch (error) {
+ setErrorMessage(DEFAULT_ERROR);
+ setCurrentState(ModalState.ERROR);
}
- }, []);
+ }, [apiClient]);
if (currentState === ModalState.ERROR) {
return (
diff --git a/apps/vx-scan/frontend/src/hooks/use_precinct_scanner_status.test.ts b/apps/vx-scan/frontend/src/hooks/use_precinct_scanner_status.test.ts
deleted file mode 100644
index 258468dd5..000000000
--- a/apps/vx-scan/frontend/src/hooks/use_precinct_scanner_status.test.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { renderHook } from '@testing-library/react-hooks';
-import { Scan } from '@votingworks/api';
-import { sleep } from '@votingworks/utils';
-import fetchMock, { MockResponseFunction } from 'fetch-mock';
-import { advanceTimersAndPromises } from '@votingworks/test-utils';
-import { usePrecinctScannerStatus } from './use_precinct_scanner_status';
-
-const statusNoPaper: Scan.GetPrecinctScannerStatusResponse = {
- state: 'no_paper',
- ballotsCounted: 0,
- canUnconfigure: false,
-};
-
-const statusReadyToScan: Scan.GetPrecinctScannerStatusResponse = {
- state: 'ready_to_scan',
- ballotsCounted: 0,
- canUnconfigure: false,
-};
-
-beforeEach(() => {
- jest.useFakeTimers();
-});
-
-test('initial state', () => {
- const { result } = renderHook(() => usePrecinctScannerStatus());
- expect(result.current).toBeUndefined();
-});
-
-test('updates from /scanner/status', async () => {
- const { result } = renderHook(() => usePrecinctScannerStatus());
-
- // first update
- fetchMock.getOnce('/precinct-scanner/scanner/status', {
- body: statusNoPaper,
- });
- await advanceTimersAndPromises(1);
- expect(result.current?.state).toBe('no_paper');
-
- // second update
- fetchMock.getOnce(
- '/precinct-scanner/scanner/status',
- { body: statusReadyToScan },
- { overwriteRoutes: false }
- );
- await advanceTimersAndPromises(1);
- expect(result.current?.state).toBe('ready_to_scan');
-});
-
-test('disabling', async () => {
- const { result } = renderHook(() => usePrecinctScannerStatus(false));
-
- fetchMock.getOnce('/precinct-scanner/scanner/status', {
- body: statusNoPaper,
- });
- await advanceTimersAndPromises(100);
-
- expect(result.current).toBeUndefined();
-});
-
-test('issues one status check at a time', async () => {
- const statusEndpoint = jest.fn<
- ReturnType,
- Parameters
- >(async () => {
- await sleep(5);
- return new Response(JSON.stringify(statusNoPaper), {
- headers: { 'Content-Type': 'application/json' },
- });
- });
-
- const { result } = renderHook(() => usePrecinctScannerStatus());
-
- fetchMock.get('/precinct-scanner/scanner/status', statusEndpoint);
- expect(result.current).toBeUndefined();
-
- await advanceTimersAndPromises(6);
- expect(result.current).toMatchObject({ state: 'no_paper' });
-
- await advanceTimersAndPromises(6);
- expect(statusEndpoint.mock.calls.length).toEqual(2);
-});
diff --git a/apps/vx-scan/frontend/src/hooks/use_precinct_scanner_status.test.tsx b/apps/vx-scan/frontend/src/hooks/use_precinct_scanner_status.test.tsx
new file mode 100644
index 000000000..41fd905b7
--- /dev/null
+++ b/apps/vx-scan/frontend/src/hooks/use_precinct_scanner_status.test.tsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import { renderHook } from '@testing-library/react-hooks';
+import { Scan } from '@votingworks/api';
+import { deferred } from '@votingworks/utils';
+import { advanceTimersAndPromises } from '@votingworks/test-utils';
+import { usePrecinctScannerStatus } from './use_precinct_scanner_status';
+import { ApiClientContext } from '../api/api';
+import {
+ createApiMock,
+ statusNoPaper,
+} from '../../test/helpers/mock_api_client';
+import { scannerStatus } from '../../test/helpers/helpers';
+
+const apiMock = createApiMock();
+
+beforeEach(() => {
+ jest.useFakeTimers();
+ apiMock.mockApiClient.reset();
+});
+
+afterEach(() => {
+ apiMock.mockApiClient.assertComplete();
+});
+
+function render(interval?: number | false) {
+ return renderHook(() => usePrecinctScannerStatus(interval), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+}
+
+test('initial state', () => {
+ const { result } = render();
+ expect(result.current).toBeUndefined();
+});
+
+test('updates from /scanner/status', async () => {
+ const { result } = render();
+
+ // first update
+ apiMock.expectGetScannerStatus(statusNoPaper);
+ await advanceTimersAndPromises(1);
+ expect(result.current?.state).toBe('no_paper');
+
+ // second update
+ apiMock.expectGetScannerStatus(scannerStatus({ state: 'ready_to_scan' }));
+ await advanceTimersAndPromises(1);
+ expect(result.current?.state).toBe('ready_to_scan');
+});
+
+test('disabling', async () => {
+ const { result } = render(false);
+ await advanceTimersAndPromises(1);
+ expect(result.current).toBeUndefined();
+});
+
+test('issues one status check at a time', async () => {
+ apiMock.mockApiClient.getScannerStatus
+ .expectCallWith()
+ .resolves(statusNoPaper);
+ const { promise, resolve } = deferred();
+ apiMock.mockApiClient.getScannerStatus.expectCallWith().returns(promise);
+ const { result } = render(1000);
+ expect(result.current).toBeUndefined();
+ await advanceTimersAndPromises(1);
+ expect(result.current).toMatchObject({ state: 'no_paper' });
+ await advanceTimersAndPromises(2);
+ expect(result.current).toMatchObject({ state: 'no_paper' });
+ resolve(scannerStatus({ state: 'ready_to_scan' }));
+ await advanceTimersAndPromises(1);
+ expect(result.current).toMatchObject({ state: 'ready_to_scan' });
+});
diff --git a/apps/vx-scan/frontend/src/hooks/use_precinct_scanner_status.ts b/apps/vx-scan/frontend/src/hooks/use_precinct_scanner_status.ts
index 2987606af..ba62cef15 100644
--- a/apps/vx-scan/frontend/src/hooks/use_precinct_scanner_status.ts
+++ b/apps/vx-scan/frontend/src/hooks/use_precinct_scanner_status.ts
@@ -3,12 +3,13 @@ import { Optional } from '@votingworks/types';
import { useCancelablePromise } from '@votingworks/ui';
import { useRef, useState } from 'react';
import useInterval from 'use-interval';
-import { getStatus } from '../api/scan';
+import { useApiClient } from '../api/api';
import { POLLING_INTERVAL_FOR_SCANNER_STATUS_MS } from '../config/globals';
export function usePrecinctScannerStatus(
interval: number | false = POLLING_INTERVAL_FOR_SCANNER_STATUS_MS
): Optional {
+ const apiClient = useApiClient();
const [status, setStatus] = useState();
const isFetchingStatus = useRef(false);
const makeCancelable = useCancelablePromise();
@@ -20,7 +21,7 @@ export function usePrecinctScannerStatus(
try {
isFetchingStatus.current = true;
- const currentStatus = await makeCancelable(getStatus());
+ const currentStatus = await makeCancelable(apiClient.getScannerStatus());
setStatus(currentStatus);
} finally {
isFetchingStatus.current = false;
diff --git a/apps/vx-scan/frontend/src/screens/election_manager_screen.test.tsx b/apps/vx-scan/frontend/src/screens/election_manager_screen.test.tsx
index 55a804381..6ac198886 100644
--- a/apps/vx-scan/frontend/src/screens/election_manager_screen.test.tsx
+++ b/apps/vx-scan/frontend/src/screens/election_manager_screen.test.tsx
@@ -7,7 +7,6 @@ import {
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { Scan } from '@votingworks/api';
import {
electionMinimalExhaustiveSampleSinglePrecinctDefinition,
electionSampleDefinition,
@@ -16,30 +15,33 @@ import { fakeKiosk, Inserted } from '@votingworks/test-utils';
import { singlePrecinctSelectionFor, usbstick } from '@votingworks/utils';
import MockDate from 'mockdate';
import React from 'react';
+import {
+ createApiMock,
+ statusNoPaper,
+} from '../../test/helpers/mock_api_client';
import { renderInAppContext } from '../../test/helpers/render_in_app_context';
+import { ApiClientContext } from '../api/api';
import { AppContextInterface } from '../contexts/app_context';
import {
ElectionManagerScreen,
ElectionManagerScreenProps,
} from './election_manager_screen';
+const apiMock = createApiMock();
+
beforeEach(() => {
MockDate.set('2020-10-31T00:00:00.000Z');
jest.useFakeTimers();
window.location.href = '/';
window.kiosk = fakeKiosk();
+ apiMock.mockApiClient.reset();
});
afterEach(() => {
window.kiosk = undefined;
+ apiMock.mockApiClient.assertComplete();
});
-const scannerStatus: Scan.PrecinctScannerStatus = {
- state: 'no_paper',
- ballotsCounted: 0,
- canUnconfigure: false,
-};
-
function renderScreen({
appContextProps = {},
electionManagerScreenProps = {},
@@ -52,18 +54,20 @@ function renderScreen({
...appContextProps,
};
return renderInAppContext(
- ,
+
+
+ ,
electionManagerScreenAppContextProps
);
}
@@ -139,7 +143,7 @@ test('unconfigure does not eject a usb drive that is not mounted', () => {
const unconfigureFn = jest.fn();
renderScreen({
electionManagerScreenProps: {
- scannerStatus: { ...scannerStatus, canUnconfigure: true },
+ scannerStatus: { ...statusNoPaper, canUnconfigure: true },
unconfigure: unconfigureFn,
usbDrive: { status: usbstick.UsbDriveStatus.absent, eject: ejectFn },
},
@@ -156,7 +160,7 @@ test('unconfigure ejects a usb drive when it is mounted', async () => {
const unconfigureFn = jest.fn();
renderScreen({
electionManagerScreenProps: {
- scannerStatus: { ...scannerStatus, canUnconfigure: true },
+ scannerStatus: { ...statusNoPaper, canUnconfigure: true },
unconfigure: unconfigureFn,
usbDrive: { status: usbstick.UsbDriveStatus.mounted, eject: ejectFn },
},
diff --git a/apps/vx-scan/frontend/src/screens/scan_warning_screen.test.tsx b/apps/vx-scan/frontend/src/screens/scan_warning_screen.test.tsx
index 0b7cba168..95e07376a 100644
--- a/apps/vx-scan/frontend/src/screens/scan_warning_screen.test.tsx
+++ b/apps/vx-scan/frontend/src/screens/scan_warning_screen.test.tsx
@@ -9,10 +9,11 @@ import {
BooleanEnvironmentVariableName,
} from '@votingworks/utils';
import React from 'react';
-import fetchMock from 'fetch-mock';
import { mockOf } from '@votingworks/test-utils';
import { ScanWarningScreen, Props } from './scan_warning_screen';
import { renderInAppContext } from '../../test/helpers/render_in_app_context';
+import { createApiMock } from '../../test/helpers/mock_api_client';
+import { ApiClientContext } from '../api/api';
jest.mock('@votingworks/utils', (): typeof import('@votingworks/utils') => {
return {
@@ -21,14 +22,26 @@ jest.mock('@votingworks/utils', (): typeof import('@votingworks/utils') => {
};
});
+const apiMock = createApiMock();
+
+beforeEach(() => {
+ apiMock.mockApiClient.reset();
+});
+
+afterEach(() => {
+ apiMock.mockApiClient.assertComplete();
+});
+
function renderScreen(props: Props) {
- return renderInAppContext();
+ return renderInAppContext(
+
+
+
+ );
}
test('overvote', () => {
- fetchMock.postOnce('/precinct-scanner/scanner/accept', {
- body: { status: 'ok' },
- });
+ apiMock.mockApiClient.acceptBallot.expectCallWith().resolves();
const contest = electionSampleDefinition.election.contests.find(
(c): c is CandidateContest => c.type === 'candidate'
)!;
@@ -57,13 +70,10 @@ test('overvote', () => {
});
userEvent.click(confirmButton);
expect(confirmButton).toBeDisabled();
- expect(fetchMock.done()).toBe(true);
});
test('overvote when casting overvotes is disallowed', () => {
- fetchMock.postOnce('/precinct-scanner/scanner/return', {
- body: { status: 'ok' },
- });
+ apiMock.mockApiClient.returnBallot.expectCallWith().resolves();
mockOf(isFeatureFlagEnabled).mockImplementation(
(flag: BooleanEnvironmentVariableName) => {
return flag === BooleanEnvironmentVariableName.DISALLOW_CASTING_OVERVOTES;
@@ -97,13 +107,10 @@ test('overvote when casting overvotes is disallowed', () => {
).not.toBeInTheDocument();
userEvent.click(screen.getByRole('button', { name: 'Return Ballot' }));
- expect(fetchMock.done()).toBe(true);
});
test('blank ballot', () => {
- fetchMock.postOnce('/precinct-scanner/scanner/accept', {
- body: { status: 'ok' },
- });
+ apiMock.mockApiClient.acceptBallot.expectCallWith().resolves();
renderScreen({
adjudicationReasonInfo: [{ type: AdjudicationReason.BlankBallot }],
});
@@ -118,13 +125,10 @@ test('blank ballot', () => {
});
userEvent.click(confirmButton);
expect(confirmButton).toBeDisabled();
- expect(fetchMock.done()).toBe(true);
});
test('undervote no votes', () => {
- fetchMock.postOnce('/precinct-scanner/scanner/accept', {
- body: { status: 'ok' },
- });
+ apiMock.mockApiClient.acceptBallot.expectCallWith().resolves();
const contest = electionSampleDefinition.election.contests.find(
(c): c is CandidateContest => c.type === 'candidate'
)!;
@@ -151,13 +155,10 @@ test('undervote no votes', () => {
});
userEvent.click(confirmButton);
expect(confirmButton).toBeDisabled();
- expect(fetchMock.done()).toBe(true);
});
test('undervote by 1', () => {
- fetchMock.postOnce('/precinct-scanner/scanner/accept', {
- body: { status: 'ok' },
- });
+ apiMock.mockApiClient.acceptBallot.expectCallWith().resolves();
const contest = electionSampleDefinition.election.contests.find(
(c): c is CandidateContest => c.type === 'candidate' && c.seats > 1
)!;
@@ -186,13 +187,10 @@ test('undervote by 1', () => {
userEvent.click(
screen.getByRole('button', { name: 'Yes, Cast Ballot As Is' })
);
- expect(fetchMock.done()).toBe(true);
});
test('multiple undervotes', () => {
- fetchMock.postOnce('/precinct-scanner/scanner/accept', {
- body: { status: 'ok' },
- });
+ apiMock.mockApiClient.acceptBallot.expectCallWith().resolves();
const contests = electionSampleDefinition.election.contests
.filter((c): c is CandidateContest => c.type === 'candidate')
.slice(0, 2);
@@ -217,14 +215,10 @@ test('multiple undervotes', () => {
userEvent.click(
screen.getByRole('button', { name: 'Yes, Cast Ballot As Is' })
);
- expect(fetchMock.done()).toBe(true);
});
test('unreadable', () => {
- fetchMock.postOnce('/precinct-scanner/scanner/accept', {
- body: { status: 'ok' },
- });
-
+ apiMock.mockApiClient.acceptBallot.expectCallWith().resolves();
renderScreen({
adjudicationReasonInfo: [
{ type: AdjudicationReason.UninterpretableBallot },
@@ -238,5 +232,4 @@ test('unreadable', () => {
});
userEvent.click(confirmButton);
expect(confirmButton).toBeDisabled();
- expect(fetchMock.done()).toBe(true);
});
diff --git a/apps/vx-scan/frontend/src/screens/scan_warning_screen.tsx b/apps/vx-scan/frontend/src/screens/scan_warning_screen.tsx
index 1f94fb830..1f601e1c9 100644
--- a/apps/vx-scan/frontend/src/screens/scan_warning_screen.tsx
+++ b/apps/vx-scan/frontend/src/screens/scan_warning_screen.tsx
@@ -26,7 +26,6 @@ import {
} from '@votingworks/utils';
import pluralize from 'pluralize';
import styled from 'styled-components';
-import * as scanner from '../api/scan';
import { ExclamationTriangle } from '../components/graphics';
import {
@@ -37,6 +36,7 @@ import {
import { AppContext } from '../contexts/app_context';
import { toSentence } from '../utils/to_sentence';
import { useSound } from '../hooks/use_sound';
+import { useApiClient } from '../api/api';
const ResponsiveButtonParagraph = styled.p`
@media (orientation: portrait) {
@@ -97,6 +97,7 @@ function OvervoteWarningScreen({
electionDefinition,
overvotes,
}: OvervoteWarningScreenProps): JSX.Element {
+ const apiClient = useApiClient();
const allowCastingOvervotes = !isFeatureFlagEnabled(
BooleanEnvironmentVariableName.DISALLOW_CASTING_OVERVOTES
);
@@ -125,7 +126,7 @@ function OvervoteWarningScreen({
{toSentence(contestNames)}.
-
}
- onConfirm={scanner.acceptBallot}
+ onConfirm={() => apiClient.acceptBallot()}
onCancel={() => setConfirmTabulate(false)}
/>
)}
@@ -170,6 +171,7 @@ function UndervoteWarningScreen({
electionDefinition,
undervotes,
}: UndervoteWarningScreenProps): JSX.Element {
+ const apiClient = useApiClient();
const [confirmTabulate, setConfirmTabulate] = useState(false);
const { contests } = electionDefinition.election;
@@ -218,7 +220,10 @@ function UndervoteWarningScreen({
)}
- Return Ballot or{' '}
+ apiClient.returnBallot()}>
+ Return Ballot
+ {' '}
+ or{' '}
setConfirmTabulate(true)}>
Cast Ballot As Is
@@ -259,7 +264,7 @@ function UndervoteWarningScreen({
}
- onConfirm={scanner.acceptBallot}
+ onConfirm={() => apiClient.acceptBallot()}
onCancel={() => setConfirmTabulate(false)}
/>
)}
@@ -268,6 +273,7 @@ function UndervoteWarningScreen({
}
function BlankBallotWarningScreen(): JSX.Element {
+ const apiClient = useApiClient();
const [confirmTabulate, setConfirmTabulate] = useState(false);
return (
@@ -276,7 +282,7 @@ function BlankBallotWarningScreen(): JSX.Element {
Review Your Ballot
No votes were found when scanning this ballot.
-
+ apiClient.returnBallot()}>
Return Ballot
{' '}
or{' '}
@@ -298,7 +304,7 @@ function BlankBallotWarningScreen(): JSX.Element {
No votes will be counted from this ballot.
}
- onConfirm={scanner.acceptBallot}
+ onConfirm={() => apiClient.acceptBallot()}
onCancel={() => setConfirmTabulate(false)}
/>
)}
@@ -307,6 +313,7 @@ function BlankBallotWarningScreen(): JSX.Element {
}
function OtherReasonWarningScreen(): JSX.Element {
+ const apiClient = useApiClient();
const [confirmTabulate, setConfirmTabulate] = useState(false);
return (
@@ -315,7 +322,7 @@ function OtherReasonWarningScreen(): JSX.Element {
Scanning Failed
There was a problem scanning this ballot.
-
+ apiClient.returnBallot()}>
Return Ballot
{' '}
or{' '}
@@ -335,7 +342,7 @@ function OtherReasonWarningScreen(): JSX.Element {
No votes will be recorded for this ballot.
}
- onConfirm={scanner.acceptBallot}
+ onConfirm={() => apiClient.acceptBallot()}
onCancel={() => setConfirmTabulate(false)}
/>
)}
diff --git a/apps/vx-scan/frontend/src/screens/unconfigured_election_screen.tsx b/apps/vx-scan/frontend/src/screens/unconfigured_election_screen.tsx
index cf55a8ffd..a78a655a2 100644
--- a/apps/vx-scan/frontend/src/screens/unconfigured_election_screen.tsx
+++ b/apps/vx-scan/frontend/src/screens/unconfigured_election_screen.tsx
@@ -16,6 +16,7 @@ import {
QuestionCircle,
IndeterminateProgressBar,
} from '../components/graphics';
+import { useApiClient } from '../api/api';
interface Props {
usbDriveStatus: usbstick.UsbDriveStatus;
@@ -26,6 +27,7 @@ export function UnconfiguredElectionScreen({
usbDriveStatus,
refreshConfig,
}: Props): JSX.Element {
+ const apiClient = useApiClient();
const [errorMessage, setErrorMessage] = useState('');
const [isLoadingBallotPackage, setIsLoadingBallotPackage] = useState(false);
@@ -74,7 +76,7 @@ export function UnconfiguredElectionScreen({
// eslint-disable-next-line vx/gts-safe-number-parse
[...ballotPackages].sort((a, b) => +b.ctime - +a.ctime)[0]
);
- addTemplates(ballotPackage)
+ addTemplates(apiClient, ballotPackage)
.on('configuring', () => {
setCurrentUploadingBallotIndex(0);
setTotalTemplates(ballotPackage.ballots.length);
diff --git a/apps/vx-scan/frontend/test/helpers/mock_api_client.ts b/apps/vx-scan/frontend/test/helpers/mock_api_client.ts
new file mode 100644
index 000000000..426dddd66
--- /dev/null
+++ b/apps/vx-scan/frontend/test/helpers/mock_api_client.ts
@@ -0,0 +1,71 @@
+import { Scan } from '@votingworks/api';
+import { electionSampleDefinition } from '@votingworks/fixtures';
+import { ALL_PRECINCTS_SELECTION } from '@votingworks/utils';
+import {
+ ElectionDefinition,
+ PollsState,
+ PrecinctSelection,
+} from '@votingworks/types';
+import { createMockClient } from '@votingworks/grout-test-utils';
+// eslint-disable-next-line vx/gts-no-import-export-type
+import type { Api } from '@votingworks/vx-scan-backend';
+
+const defaultConfig: Scan.PrecinctScannerConfig = {
+ ...Scan.InitialPrecinctScannerConfig,
+ electionDefinition: electionSampleDefinition,
+ precinctSelection: ALL_PRECINCTS_SELECTION,
+};
+
+export const statusNoPaper: Scan.PrecinctScannerStatus = {
+ state: 'no_paper',
+ canUnconfigure: false,
+ ballotsCounted: 0,
+};
+
+/**
+ * Creates a VxScan specific wrapper around commonly used methods from the Grout
+ * mock API client to make it easier to use for our specific test needs
+ */
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export function createApiMock() {
+ const mockApiClient = createMockClient();
+ return {
+ mockApiClient,
+
+ expectGetConfig(config: Partial = {}): void {
+ mockApiClient.getConfig.expectCallWith().resolves({
+ ...defaultConfig,
+ ...config,
+ });
+ },
+
+ expectSetElection(electionDefinition: ElectionDefinition): void {
+ mockApiClient.setElection
+ .expectCallWith({ electionData: electionDefinition.electionData })
+ .resolves();
+ },
+
+ expectSetPrecinct(precinctSelection: PrecinctSelection): void {
+ mockApiClient.setPrecinctSelection
+ .expectCallWith({ precinctSelection })
+ .resolves();
+ },
+
+ expectSetTestMode(isTestMode: boolean): void {
+ mockApiClient.setTestMode.expectCallWith({ isTestMode }).resolves();
+ },
+
+ expectGetScannerStatus(
+ status: Scan.PrecinctScannerStatus,
+ times = 1
+ ): void {
+ for (let i = 0; i < times; i += 1) {
+ mockApiClient.getScannerStatus.expectCallWith().resolves(status);
+ }
+ },
+
+ expectSetPollsState(pollsState: PollsState): void {
+ mockApiClient.setPollsState.expectCallWith({ pollsState }).resolves();
+ },
+ };
+}
diff --git a/apps/vx-scan/frontend/test/helpers/mock_config.ts b/apps/vx-scan/frontend/test/helpers/mock_config.ts
deleted file mode 100644
index fb6805613..000000000
--- a/apps/vx-scan/frontend/test/helpers/mock_config.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import fetchMock from 'fetch-mock';
-import { Scan } from '@votingworks/api';
-import { electionSampleDefinition } from '@votingworks/fixtures';
-import { ALL_PRECINCTS_SELECTION, typedAs } from '@votingworks/utils';
-import {
- ElectionDefinition,
- PollsState,
- PrecinctSelection,
-} from '@votingworks/types';
-
-const defaultConfig: Scan.PrecinctScannerConfig = {
- ...Scan.InitialPrecinctScannerConfig,
- electionDefinition: electionSampleDefinition,
- precinctSelection: ALL_PRECINCTS_SELECTION,
-};
-
-export function mockConfig(
- paramsConfig: Partial = {}
-): {
- mockElectionDefinitionChange: (
- electionDefinition: ElectionDefinition
- ) => void;
- mockPrecinctChange: (precinctSelection: PrecinctSelection) => void;
- mockPollsChange: (pollsState: PollsState) => void;
- mockTestModeChange: (isTestMode: boolean) => void;
- mockBallotBagReplaced: (ballotCountWhenBallotBagLastReplaced: number) => void;
-} {
- const config: Scan.PrecinctScannerConfig = {
- ...defaultConfig,
- ...paramsConfig,
- };
-
- fetchMock.get(
- '/precinct-scanner/config',
- {
- body: typedAs(config),
- },
- {
- overwriteRoutes: true,
- }
- );
-
- return {
- mockElectionDefinitionChange: (electionDefinition: ElectionDefinition) => {
- fetchMock.patchOnce('/precinct-scanner/config/election', {
- body: typedAs({ status: 'ok' }),
- status: 200,
- });
- config.electionDefinition = electionDefinition;
- },
-
- mockPrecinctChange: (precinctSelection: PrecinctSelection) => {
- fetchMock.patchOnce(
- {
- url: '/precinct-scanner/config/precinct',
- body: { precinctSelection },
- },
- {
- body: { status: 'ok' },
- }
- );
- config.precinctSelection = precinctSelection;
- },
-
- mockPollsChange: (pollsState: PollsState) => {
- fetchMock.patchOnce(
- { url: '/precinct-scanner/config/polls', body: { pollsState } },
- {
- body: { status: 'ok' },
- }
- );
- config.pollsState = pollsState;
- },
-
- mockTestModeChange: (testMode: boolean) => {
- fetchMock.patchOnce(
- { url: '/precinct-scanner/config/testMode', body: { testMode } },
- {
- body: { status: 'ok' },
- status: 200,
- }
- );
- config.isTestMode = testMode;
- },
-
- mockBallotBagReplaced: (ballotCountWhenBallotBagLastReplaced: number) => {
- fetchMock.patchOnce(
- {
- url: '/precinct-scanner/config/ballotBagReplaced',
- },
- {
- body: {
- status: 'ok',
- },
- status: 200,
- }
- );
- config.ballotCountWhenBallotBagLastReplaced =
- ballotCountWhenBallotBagLastReplaced;
- },
- };
-}
diff --git a/apps/vx-scan/frontend/tsconfig.json b/apps/vx-scan/frontend/tsconfig.json
index 235e7d424..93c8cd6b4 100644
--- a/apps/vx-scan/frontend/tsconfig.json
+++ b/apps/vx-scan/frontend/tsconfig.json
@@ -17,11 +17,14 @@
"include": ["src", "test", "types"],
"exclude": ["src/setupProxy.js"],
"references": [
+ { "path": "../backend/tsconfig.build.json" },
{ "path": "../../../libs/api/tsconfig.build.json" },
{ "path": "../../../libs/ballot-encoder/tsconfig.build.json" },
{ "path": "../../../libs/ballot-interpreter-vx/tsconfig.build.json" },
{ "path": "../../../libs/eslint-plugin-vx/tsconfig.build.json" },
{ "path": "../../../libs/fixtures/tsconfig.build.json" },
+ { "path": "../../../libs/grout/tsconfig.build.json" },
+ { "path": "../../../libs/grout/test-utils/tsconfig.build.json" },
{ "path": "../../../libs/logging/tsconfig.build.json" },
{ "path": "../../../libs/test-utils/tsconfig.build.json" },
{ "path": "../../../libs/types/tsconfig.build.json" },
diff --git a/libs/grout/.eslintignore b/libs/grout/.eslintignore
index 6bf89faa9..9dde9fd96 100644
--- a/libs/grout/.eslintignore
+++ b/libs/grout/.eslintignore
@@ -1,4 +1,5 @@
*.js
*.d.ts
build
-coverage
\ No newline at end of file
+coverage
+test-utils
\ No newline at end of file
diff --git a/libs/grout/README.md b/libs/grout/README.md
index dbe46fb4e..5b3e844ed 100644
--- a/libs/grout/README.md
+++ b/libs/grout/README.md
@@ -111,6 +111,40 @@ try {
}
```
+### Testing
+
+To test your server API, you can simply run the server and use the Grout client
+to call the API methods. Note that you'll need to patch `globals.fetch` with a
+Node implementation like `node-fetch`.
+
+To mock your API in client-side tests, you can use the `createMockClient` method
+from `@votingworks/grout-test-utils`:
+
+```ts
+import { createMockClient } from '@votingworks/grout-test-utils';
+const mockApiClient = createMockClient();
+
+// Ensure the mock is in a clean state before each test
+beforeEach(() => {
+ mockApiClient.reset();
+});
+
+// Ensure all expected calls were made after each test
+afterEach(() => {
+ mockApiClient.assertComplete();
+});
+
+test('it works', () => {
+ // Each method on the mock client is a MockFunction (see @votingworks/test-utils).
+ mockApiClient.getAllPeople
+ .expectCallWith()
+ .resolves([{ name: 'Alice', age: 99 }]);
+ expect(await mockApiClient.getAllPeople()).toEqual([
+ { name: 'Alice', age: 99 },
+ ]);
+});
+```
+
### tsconfig settings
Grout works out of the box with our default `tsconfig.json` settings. However,
diff --git a/libs/grout/package.json b/libs/grout/package.json
index a00d4bc7a..fa01ae75d 100644
--- a/libs/grout/package.json
+++ b/libs/grout/package.json
@@ -34,7 +34,6 @@
"@types/debug": "^4.1.7",
"@types/deep-eql": "^4.0.0",
"@types/express": "^4.17.14",
- "@votingworks/test-utils": "workspace:*",
"@votingworks/types": "workspace:*",
"debug": "^4.3.4",
"deep-eql": "^4.1.3"
@@ -53,10 +52,8 @@
"eslint-plugin-vx": "workspace:*",
"expect-type": "^0.15.0",
"express": "^4.18.2",
- "fetch-mock": "^9.11.0",
"is-ci-cli": "^2.2.0",
"jest": "^27.3.1",
- "jest-fetch-mock": "^3.0.3",
"jest-junit": "^14.0.1",
"jest-watch-typeahead": "^0.6.4",
"lint-staged": "^11.0.0",
diff --git a/libs/grout/src/client.ts b/libs/grout/src/client.ts
index 93297a8e1..cad02efc8 100644
--- a/libs/grout/src/client.ts
+++ b/libs/grout/src/client.ts
@@ -63,22 +63,35 @@ export function createClient(
debug(`Call: ${methodName}(${inputJson})`);
try {
- const response = await fetch(methodUrl(methodName, options.baseUrl), {
+ const url = methodUrl(methodName, options.baseUrl);
+ const response = await fetch(url, {
method: 'POST',
body: serialize(input),
headers: { 'Content-type': 'application/json' },
});
debug(`Response status code: ${response.status}`);
- if (
- !response.headers.get('Content-type')?.includes('application/json')
- ) {
- throw new ServerError('Response is not JSON');
- }
+ const hasJsonBody = response.headers
+ .get('Content-type')
+ ?.includes('application/json');
if (!response.ok) {
- const { message } = await response.json();
- throw new ServerError(message);
+ if (hasJsonBody) {
+ const { message } = await response.json();
+ throw new ServerError(message);
+ }
+ if (response.status === 404) {
+ throw new ServerError(
+ `Got 404 for ${url}. Are you sure the baseUrl is correct?`
+ );
+ }
+ throw new ServerError(`Got ${response.status} for ${url}`);
+ }
+
+ if (!hasJsonBody) {
+ throw new ServerError(
+ `Response content type is not JSON for ${url}`
+ );
}
const resultText = await response.text();
diff --git a/libs/grout/src/grout.test.ts b/libs/grout/src/grout.test.ts
index 6f6cd5a84..2f489e2fb 100644
--- a/libs/grout/src/grout.test.ts
+++ b/libs/grout/src/grout.test.ts
@@ -198,6 +198,22 @@ test('client accepts baseUrl with a trailing slash', async () => {
server.close();
});
+test('client errors on incorrect baseUrl', async () => {
+ const api = createApi({
+ async getStuff(): Promise {
+ return 42;
+ },
+ });
+ const { server, baseUrl } = createTestApp(api);
+ const client = createClient({
+ baseUrl: `${baseUrl}wrong`,
+ });
+ await expect(client.getStuff()).rejects.toThrow(
+ `Got 404 for ${baseUrl}wrong/getStuff. Are you sure the baseUrl is correct?`
+ );
+ server.close();
+});
+
test('client errors if response is not JSON', async () => {
const api = createApi({
async getStuff(): Promise {
@@ -223,12 +239,16 @@ test('client errors if response is not JSON', async () => {
const baseUrl = `http://localhost:${port}/api`;
const client = createClient({ baseUrl });
- await expect(client.getStuff()).rejects.toThrow('Response is not JSON');
- await expect(client.getMoreStuff()).rejects.toThrow('Response is not JSON');
+ await expect(client.getStuff()).rejects.toThrow(
+ `Response content type is not JSON for ${baseUrl}/getStuff`
+ );
+ await expect(client.getMoreStuff()).rejects.toThrow(
+ `Response content type is not JSON for ${baseUrl}/getMoreStuff`
+ );
server.close();
});
-test('client handles a variety of malformed error responses', async () => {
+test('client handles non-JSON error responses', async () => {
const api = createApi({
async getStuff(): Promise {
return 42;
@@ -247,3 +267,23 @@ test('client handles a variety of malformed error responses', async () => {
await expect(client.getStuff()).rejects.toThrow('invalid json response body');
server.close();
});
+
+test('client handles other server errors', async () => {
+ const api = createApi({
+ async getStuff(): Promise {
+ return 42;
+ },
+ });
+ const app = express();
+ app.post('/api/getStuff', (req, res) => {
+ res.status(500).send();
+ });
+ const server = app.listen();
+ const { port } = server.address() as AddressInfo;
+ const baseUrl = `http://localhost:${port}/api`;
+ const client = createClient({ baseUrl });
+ await expect(client.getStuff()).rejects.toThrow(
+ `Got 500 for ${baseUrl}/getStuff`
+ );
+ server.close();
+});
diff --git a/libs/grout/src/index.ts b/libs/grout/src/index.ts
index 00564648b..8121c97c0 100644
--- a/libs/grout/src/index.ts
+++ b/libs/grout/src/index.ts
@@ -1,4 +1,3 @@
/* istanbul ignore file */
export * from './client';
export * from './server';
-export * from './mock_client';
diff --git a/libs/grout/test-utils/.eslintignore b/libs/grout/test-utils/.eslintignore
new file mode 100644
index 000000000..6bf89faa9
--- /dev/null
+++ b/libs/grout/test-utils/.eslintignore
@@ -0,0 +1,4 @@
+*.js
+*.d.ts
+build
+coverage
\ No newline at end of file
diff --git a/libs/grout/test-utils/.eslintrc.json b/libs/grout/test-utils/.eslintrc.json
new file mode 100644
index 000000000..3b8fd1d77
--- /dev/null
+++ b/libs/grout/test-utils/.eslintrc.json
@@ -0,0 +1,3 @@
+{
+ "extends": ["plugin:vx/recommended"]
+}
diff --git a/libs/grout/test-utils/jest.config.js b/libs/grout/test-utils/jest.config.js
new file mode 100644
index 000000000..6d78c29b9
--- /dev/null
+++ b/libs/grout/test-utils/jest.config.js
@@ -0,0 +1,8 @@
+const shared = require('../../../jest.config.shared');
+
+/**
+ * @type {import('@jest/types').Config.InitialOptions}
+ */
+module.exports = {
+ ...shared,
+};
diff --git a/libs/grout/test-utils/package.json b/libs/grout/test-utils/package.json
new file mode 100644
index 000000000..b842dd852
--- /dev/null
+++ b/libs/grout/test-utils/package.json
@@ -0,0 +1,63 @@
+{
+ "name": "@votingworks/grout-test-utils",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Test utilities for testing apps using Grout",
+ "license": "GPL-3.0",
+ "author": "VotingWorks Eng ",
+ "main": "./build/index.js",
+ "types": "./build/index.d.ts",
+ "scripts": {
+ "build": "tsc --build tsconfig.build.json",
+ "clean": "rm -rf build *.tsbuildinfo",
+ "lint": "pnpm type-check && eslint .",
+ "lint:fix": "pnpm type-check && eslint . --fix",
+ "pre-commit": "lint-staged",
+ "test": "is-ci test:ci test:watch",
+ "test:ci": "jest --coverage --reporters=default --reporters=jest-junit --maxWorkers=7",
+ "test:coverage": "jest --coverage",
+ "test:watch": "jest --watch",
+ "type-check": "tsc --build"
+ },
+ "lint-staged": {
+ "*.+(css|graphql|json|less|md|mdx|sass|scss|yaml|yml)": [
+ "prettier --write"
+ ],
+ "*.+(js|jsx|ts|tsx)": [
+ "eslint --quiet --fix"
+ ],
+ "package.json": [
+ "sort-package-json"
+ ]
+ },
+ "dependencies": {
+ "@votingworks/test-utils": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/jest": "^27.0.3",
+ "@typescript-eslint/eslint-plugin": "^5.37.0",
+ "@typescript-eslint/parser": "^5.37.0",
+ "@votingworks/grout": "workspace:*",
+ "eslint": "^8.23.1",
+ "eslint-config-prettier": "^8.5.0",
+ "eslint-import-resolver-node": "^0.3.6",
+ "eslint-plugin-import": "^2.26.0",
+ "eslint-plugin-jest": "^26.1.5",
+ "eslint-plugin-prettier": "^4.2.1",
+ "eslint-plugin-vx": "workspace:*",
+ "expect-type": "^0.15.0",
+ "is-ci-cli": "^2.2.0",
+ "jest": "^27.3.1",
+ "jest-junit": "^14.0.1",
+ "jest-watch-typeahead": "^0.6.4",
+ "lint-staged": "^11.0.0",
+ "prettier": "^2.6.2",
+ "sort-package-json": "^1.50.0",
+ "ts-jest": "^27.0.7",
+ "typescript": "4.6.3"
+ },
+ "peerDependencies": {
+ "@votingworks/grout": "workspace:*"
+ },
+ "packageManager": "pnpm@5.18.10"
+}
diff --git a/libs/grout/test-utils/src/index.ts b/libs/grout/test-utils/src/index.ts
new file mode 100644
index 000000000..35f6aebac
--- /dev/null
+++ b/libs/grout/test-utils/src/index.ts
@@ -0,0 +1,2 @@
+/* istanbul ignore file */
+export * from './mock_client';
diff --git a/libs/grout/src/mock_client.test.ts b/libs/grout/test-utils/src/mock_client.test.ts
similarity index 97%
rename from libs/grout/src/mock_client.test.ts
rename to libs/grout/test-utils/src/mock_client.test.ts
index 35d18dd14..6f29574fb 100644
--- a/libs/grout/src/mock_client.test.ts
+++ b/libs/grout/test-utils/src/mock_client.test.ts
@@ -1,8 +1,7 @@
import { MockFunction } from '@votingworks/test-utils';
import { expectTypeOf } from 'expect-type';
-import { createClient } from './client';
+import { createApi, createClient } from '@votingworks/grout';
import { createMockClient } from './mock_client';
-import { createApi } from './server';
const api = createApi({
// eslint-disable-next-line @typescript-eslint/require-await
diff --git a/libs/grout/src/mock_client.ts b/libs/grout/test-utils/src/mock_client.ts
similarity index 97%
rename from libs/grout/src/mock_client.ts
rename to libs/grout/test-utils/src/mock_client.ts
index f0404f48a..f9c31ea07 100644
--- a/libs/grout/src/mock_client.ts
+++ b/libs/grout/test-utils/src/mock_client.ts
@@ -3,7 +3,7 @@ import {
MockFunction,
MockFunctionError,
} from '@votingworks/test-utils';
-import { AnyApi, AnyMethods, inferApiMethods } from './server';
+import { AnyApi, AnyMethods, inferApiMethods } from '@votingworks/grout';
type MockMethods = {
[Method in keyof Methods]: MockFunction;
diff --git a/libs/grout/test-utils/tsconfig.build.json b/libs/grout/test-utils/tsconfig.build.json
new file mode 100644
index 000000000..3e6c7fe88
--- /dev/null
+++ b/libs/grout/test-utils/tsconfig.build.json
@@ -0,0 +1,17 @@
+{
+ "extends": "./tsconfig.json",
+ "include": ["src"],
+ "exclude": ["**/*.test.ts", "**/*.test.tsx"],
+ "compilerOptions": {
+ "noEmit": false,
+ "rootDir": "src",
+ "outDir": "build",
+ "declaration": true,
+ "declarationMap": true
+ },
+ "references": [
+ { "path": "../../eslint-plugin-vx/tsconfig.build.json" },
+ { "path": "../../grout/tsconfig.build.json" },
+ { "path": "../../test-utils/tsconfig.build.json" }
+ ]
+}
diff --git a/libs/grout/test-utils/tsconfig.json b/libs/grout/test-utils/tsconfig.json
new file mode 100644
index 000000000..fa9c2a2ee
--- /dev/null
+++ b/libs/grout/test-utils/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "extends": "../../../tsconfig.shared.json",
+ "include": ["src", "test"],
+ "exclude": [],
+ "compilerOptions": {
+ "module": "commonjs",
+ "lib": ["dom", "esnext"],
+ "composite": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true
+ },
+ "references": [
+ { "path": "../../eslint-plugin-vx/tsconfig.build.json" },
+ { "path": "../../grout/tsconfig.build.json" },
+ { "path": "../../test-utils/tsconfig.build.json" }
+ ]
+}
diff --git a/libs/grout/tsconfig.build.json b/libs/grout/tsconfig.build.json
index b848db4be..7e7a4fa58 100644
--- a/libs/grout/tsconfig.build.json
+++ b/libs/grout/tsconfig.build.json
@@ -11,7 +11,6 @@
},
"references": [
{ "path": "../eslint-plugin-vx/tsconfig.build.json" },
- { "path": "../test-utils/tsconfig.build.json" },
{ "path": "../types/tsconfig.build.json" }
]
}
diff --git a/libs/grout/tsconfig.json b/libs/grout/tsconfig.json
index 3b48e73b3..a1eb5f9d8 100644
--- a/libs/grout/tsconfig.json
+++ b/libs/grout/tsconfig.json
@@ -12,7 +12,6 @@
},
"references": [
{ "path": "../eslint-plugin-vx/tsconfig.build.json" },
- { "path": "../test-utils/tsconfig.build.json" },
{ "path": "../types/tsconfig.build.json" }
]
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d1ad2dcdd..2be8cb46e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -25,6 +25,7 @@ importers:
'@votingworks/data': link:../../../libs/data
'@votingworks/db': link:../../../libs/db
'@votingworks/fixtures': link:../../../libs/fixtures
+ '@votingworks/grout': link:../../../libs/grout
'@votingworks/image-utils': link:../../../libs/image-utils
'@votingworks/logging': link:../../../libs/logging
'@votingworks/plustek-sdk': link:../../../libs/plustek-sdk
@@ -61,6 +62,7 @@ importers:
'@types/luxon': 1.27.1
'@types/multer': 1.4.7
'@types/node': 16.11.29
+ '@types/node-fetch': 2.6.2
'@types/supertest': 2.0.10
'@types/tmp': 0.2.3
'@types/uuid': 8.3.4
@@ -80,6 +82,7 @@ importers:
jest-watch-typeahead: 0.6.5_jest@26.6.3
lint-staged: 10.5.4
nock: 13.1.0
+ node-fetch: 2.6.7
nodemon: 2.0.20
prettier: 2.7.1
sort-package-json: 1.53.1
@@ -97,6 +100,7 @@ importers:
'@types/luxon': ^1.26.5
'@types/multer': ^1.4.7
'@types/node': 16.11.29
+ '@types/node-fetch': ^2.6.2
'@types/supertest': ^2.0.10
'@types/tmp': ^0.2.0
'@types/uuid': ^8.3.0
@@ -110,6 +114,7 @@ importers:
'@votingworks/data': workspace:*
'@votingworks/db': workspace:*
'@votingworks/fixtures': workspace:*
+ '@votingworks/grout': workspace:*
'@votingworks/image-utils': workspace:*
'@votingworks/logging': workspace:*
'@votingworks/plustek-sdk': workspace:*
@@ -143,6 +148,7 @@ importers:
memory-streams: ^0.1.3
multer: ^1.4.2
nock: ^13.1.0
+ node-fetch: ^2.6.0
nodemon: ^2.0.20
prettier: ^2.6.2
rxjs: ^7.5.5
@@ -171,6 +177,7 @@ importers:
'@votingworks/api': link:../../../libs/api
'@votingworks/ballot-interpreter-vx': link:../../../libs/ballot-interpreter-vx
'@votingworks/fixtures': link:../../../libs/fixtures
+ '@votingworks/grout': link:../../../libs/grout
'@votingworks/logging': link:../../../libs/logging
'@votingworks/types': link:../../../libs/types
'@votingworks/ui': link:../../../libs/ui
@@ -216,7 +223,9 @@ importers:
'@typescript-eslint/eslint-plugin': 5.37.0_9443e33d3af89f89dc1c3d6ee69618ca
'@typescript-eslint/parser': 5.37.0_eslint@8.26.0+typescript@4.6.3
'@vitejs/plugin-react': 1.3.2
+ '@votingworks/grout-test-utils': link:../../../libs/grout/test-utils
'@votingworks/test-utils': link:../../../libs/test-utils
+ '@votingworks/vx-scan-backend': link:../backend
eslint: 8.26.0
eslint-config-airbnb: 19.0.4_4847ebbeb869dc623f7a4db1077dc88d
eslint-config-prettier: 8.5.0_eslint@8.26.0
@@ -276,11 +285,14 @@ importers:
'@votingworks/api': workspace:*
'@votingworks/ballot-interpreter-vx': workspace:*
'@votingworks/fixtures': workspace:*
+ '@votingworks/grout': workspace:*
+ '@votingworks/grout-test-utils': workspace:*
'@votingworks/logging': workspace:*
'@votingworks/test-utils': workspace:*
'@votingworks/types': workspace:*
'@votingworks/ui': workspace:*
'@votingworks/utils': workspace:*
+ '@votingworks/vx-scan-backend': workspace:*
base64-js: ^1.3.1
buffer: ^6.0.3
debug: ^4.3.1
@@ -2332,7 +2344,6 @@ importers:
'@types/debug': 4.1.7
'@types/deep-eql': 4.0.0
'@types/express': 4.17.14
- '@votingworks/test-utils': link:../test-utils
'@votingworks/types': link:../types
debug: 4.3.4
deep-eql: 4.1.3
@@ -2350,10 +2361,8 @@ importers:
eslint-plugin-vx: link:../eslint-plugin-vx
expect-type: 0.15.0
express: 4.18.2
- fetch-mock: 9.11.0
is-ci-cli: 2.2.0
jest: 27.5.1
- jest-fetch-mock: 3.0.3
jest-junit: 14.0.1
jest-watch-typeahead: 0.6.5_jest@27.5.1
lint-staged: 11.0.0
@@ -2370,7 +2379,6 @@ importers:
'@types/node-fetch': ^2.6.0
'@typescript-eslint/eslint-plugin': ^5.37.0
'@typescript-eslint/parser': ^5.37.0
- '@votingworks/test-utils': workspace:*
'@votingworks/types': workspace:*
debug: ^4.3.4
deep-eql: ^4.1.3
@@ -2383,10 +2391,8 @@ importers:
eslint-plugin-vx: workspace:*
expect-type: ^0.15.0
express: ^4.18.2
- fetch-mock: ^9.11.0
is-ci-cli: ^2.2.0
jest: ^27.3.1
- jest-fetch-mock: ^3.0.3
jest-junit: ^14.0.1
jest-watch-typeahead: ^0.6.4
lint-staged: ^11.0.0
@@ -2395,6 +2401,54 @@ importers:
sort-package-json: ^1.50.0
ts-jest: ^27.0.7
typescript: 4.6.3
+ libs/grout/test-utils:
+ dependencies:
+ '@votingworks/test-utils': link:../../test-utils
+ devDependencies:
+ '@types/jest': 27.4.1
+ '@typescript-eslint/eslint-plugin': 5.37.0_9443e33d3af89f89dc1c3d6ee69618ca
+ '@typescript-eslint/parser': 5.37.0_eslint@8.26.0+typescript@4.6.3
+ '@votingworks/grout': link:..
+ eslint: 8.26.0
+ eslint-config-prettier: 8.5.0_eslint@8.26.0
+ eslint-import-resolver-node: 0.3.6
+ eslint-plugin-import: 2.26.0_eslint@8.26.0+typescript@4.6.3
+ eslint-plugin-jest: 26.6.0_df5e750bef2e1448a6529f143dfbf93b
+ eslint-plugin-prettier: 4.2.1_03516513155bd96520649d3136b95513
+ eslint-plugin-vx: link:../../eslint-plugin-vx
+ expect-type: 0.15.0
+ is-ci-cli: 2.2.0
+ jest: 27.5.1
+ jest-junit: 14.0.1
+ jest-watch-typeahead: 0.6.5_jest@27.5.1
+ lint-staged: 11.0.0
+ prettier: 2.7.1
+ sort-package-json: 1.53.1
+ ts-jest: 27.1.5_9985e1834e803358b7be1e6ce5ca0eea
+ typescript: 4.6.3
+ specifiers:
+ '@types/jest': ^27.0.3
+ '@typescript-eslint/eslint-plugin': ^5.37.0
+ '@typescript-eslint/parser': ^5.37.0
+ '@votingworks/grout': workspace:*
+ '@votingworks/test-utils': workspace:*
+ eslint: ^8.23.1
+ eslint-config-prettier: ^8.5.0
+ eslint-import-resolver-node: ^0.3.6
+ eslint-plugin-import: ^2.26.0
+ eslint-plugin-jest: ^26.1.5
+ eslint-plugin-prettier: ^4.2.1
+ eslint-plugin-vx: workspace:*
+ expect-type: ^0.15.0
+ is-ci-cli: ^2.2.0
+ jest: ^27.3.1
+ jest-junit: ^14.0.1
+ jest-watch-typeahead: ^0.6.4
+ lint-staged: ^11.0.0
+ prettier: ^2.6.2
+ sort-package-json: ^1.50.0
+ ts-jest: ^27.0.7
+ typescript: 4.6.3
libs/image-utils:
dependencies:
'@votingworks/types': link:../types
@@ -11669,7 +11723,7 @@ packages:
/@types/glob/7.2.0:
dependencies:
'@types/minimatch': 3.0.5
- '@types/node': 17.0.36
+ '@types/node': 18.8.0
resolution:
integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==
/@types/graceful-fs/4.1.5:
@@ -22096,8 +22150,8 @@ packages:
'@types/glob': 7.2.0
array-union: 2.1.0
dir-glob: 3.0.1
- fast-glob: 3.2.7
- glob: 7.2.0
+ fast-glob: 3.2.11
+ glob: 7.2.3
ignore: 5.2.0
merge2: 1.4.1
slash: 3.0.0
@@ -26316,7 +26370,7 @@ packages:
/jest-util/27.5.1:
dependencies:
'@jest/types': 27.5.1
- '@types/node': 18.7.15
+ '@types/node': 18.8.0
chalk: 4.1.2
ci-info: 3.3.2
graceful-fs: 4.2.10
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index cf653fce9..2e6510db3 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -14,6 +14,7 @@ packages:
# include all the libraries and our custom types
- 'libs/*'
- 'libs/@types/*'
+ - 'libs/grout/*'
# include all integration tests
- 'integration-testing/*'
diff --git a/script/src/validate-monorepo/validation/index.ts b/script/src/validate-monorepo/validation/index.ts
index 65fd792e9..787563b03 100644
--- a/script/src/validate-monorepo/validation/index.ts
+++ b/script/src/validate-monorepo/validation/index.ts
@@ -3,7 +3,7 @@ import { getWorkspacePackagePaths } from '../pnpm';
import * as circleci from './circleci';
import * as pkgs from './packages';
import * as tsconfig from './tsconfig';
-import { readdir } from './util';
+import { flatten, readdir } from './util';
export type ValidationIssue =
| pkgs.ValidationIssue
@@ -12,10 +12,12 @@ export type ValidationIssue =
export async function* validateMonorepo(): AsyncGenerator {
const root = join(__dirname, '../../../..');
+ const apps = await readdir(join(root, 'apps'));
+ const appPackages = (await Promise.all(apps.map(readdir))).flat();
const services = await readdir(join(root, 'services'));
const frontends = await readdir(join(root, 'frontends'));
const libs = await readdir(join(root, 'libs'));
- const packages = [...services, ...frontends, ...libs];
+ const packages = [...services, ...frontends, ...libs, ...appPackages];
yield* pkgs.checkConfig({ packages: [root, ...packages] });
yield* tsconfig.checkConfig({ packages });
diff --git a/tsconfig.shared.json b/tsconfig.shared.json
index a8b428493..4ea912471 100644
--- a/tsconfig.shared.json
+++ b/tsconfig.shared.json
@@ -19,6 +19,6 @@
"noPropertyAccessFromIndexSignature": true,
// Treat `[…]` property accesses as potentially undefined.
- "noUncheckedIndexedAccess": true,
+ "noUncheckedIndexedAccess": true
}
}
diff --git a/vxsuite.code-workspace b/vxsuite.code-workspace
index b7f84ec13..6a93dfd42 100644
--- a/vxsuite.code-workspace
+++ b/vxsuite.code-workspace
@@ -84,6 +84,10 @@
"name": "libs/grout",
"path": "libs/grout"
},
+ {
+ "name": "libs/grout/test-utils",
+ "path": "libs/grout/test-utils"
+ },
{
"name": "libs/image-utils",
"path": "libs/image-utils"