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)}. - {allowCastingOvervotes && ( @@ -153,7 +154,7 @@ function OvervoteWarningScreen({

} - 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({

)} - or{' '} + {' '} + or{' '} @@ -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.

- {' '} 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.

- {' '} 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"