diff --git a/.env b/.env index a6404dbda..1b08775c5 100644 --- a/.env +++ b/.env @@ -6,6 +6,7 @@ REACT_APP_VX_ENABLE_DEV_DOCK=TRUE REACT_APP_VX_SKIP_PIN_ENTRY=FALSE REACT_APP_VX_USE_MOCK_CARDS=FALSE REACT_APP_VX_USE_MOCK_USB_DRIVE=FALSE +REACT_APP_VX_USE_MOCK_PRINTER=FALSE REACT_APP_VX_SKIP_CVR_ELECTION_HASH_CHECK=FALSE REACT_APP_VX_SKIP_SCAN_ELECTION_HASH_CHECK=FALSE REACT_APP_VX_SKIP_ELECTION_PACKAGE_AUTHENTICATION=FALSE diff --git a/apps/admin/backend/src/app.ts b/apps/admin/backend/src/app.ts index 0e62beea9..649bc1dbd 100644 --- a/apps/admin/backend/src/app.ts +++ b/apps/admin/backend/src/app.ts @@ -1069,6 +1069,6 @@ export function buildApp({ const app: Application = express(); const api = buildApi({ auth, workspace, logger, usbDrive, printer }); app.use('/api', grout.buildRouter(api, express)); - useDevDockRouter(app, express); + useDevDockRouter(app, express, 'admin'); return app; } diff --git a/apps/central-scan/backend/src/app.ts b/apps/central-scan/backend/src/app.ts index 15b334b0a..dd4596c7a 100644 --- a/apps/central-scan/backend/src/app.ts +++ b/apps/central-scan/backend/src/app.ts @@ -293,7 +293,7 @@ export function buildCentralScannerApp({ importer, }); app.use('/api', grout.buildRouter(api, express)); - useDevDockRouter(app, express); + useDevDockRouter(app, express, 'central-scan'); const deprecatedApiRouter = express.Router(); deprecatedApiRouter.use(express.raw()); diff --git a/apps/mark-scan/backend/src/app.ts b/apps/mark-scan/backend/src/app.ts index e08e00ca4..59f3e50c6 100644 --- a/apps/mark-scan/backend/src/app.ts +++ b/apps/mark-scan/backend/src/app.ts @@ -348,6 +348,6 @@ export function buildApp( const app: Application = express(); const api = buildApi(auth, usbDrive, logger, workspace, stateMachine); app.use('/api', grout.buildRouter(api, express)); - useDevDockRouter(app, express); + useDevDockRouter(app, express, 'mark-scan'); return app; } diff --git a/apps/mark/backend/src/app.ts b/apps/mark/backend/src/app.ts index 3ac2022d8..d45d72911 100644 --- a/apps/mark/backend/src/app.ts +++ b/apps/mark/backend/src/app.ts @@ -266,6 +266,6 @@ export function buildApp( const app: Application = express(); const api = buildApi(auth, usbDrive, logger, workspace); app.use('/api', grout.buildRouter(api, express)); - useDevDockRouter(app, express); + useDevDockRouter(app, express, 'mark'); return app; } diff --git a/apps/scan/backend/src/app.ts b/apps/scan/backend/src/app.ts index cf121127f..68b9791a3 100644 --- a/apps/scan/backend/src/app.ts +++ b/apps/scan/backend/src/app.ts @@ -419,6 +419,6 @@ export function buildApp({ const app: Application = express(); const api = buildApi({ auth, machine, workspace, usbDrive, printer, logger }); app.use('/api', grout.buildRouter(api, express)); - useDevDockRouter(app, express); + useDevDockRouter(app, express, 'scan'); return app; } diff --git a/libs/dev-dock/backend/package.json b/libs/dev-dock/backend/package.json index f7909baed..45b0cdc10 100644 --- a/libs/dev-dock/backend/package.json +++ b/libs/dev-dock/backend/package.json @@ -34,6 +34,7 @@ "@votingworks/auth": "workspace:*", "@votingworks/basics": "workspace:*", "@votingworks/grout": "workspace:*", + "@votingworks/printing": "workspace:*", "@votingworks/types": "workspace:*", "@votingworks/usb-drive": "workspace:*", "@votingworks/utils": "workspace:*" diff --git a/libs/dev-dock/backend/src/dev_dock_api.test.ts b/libs/dev-dock/backend/src/dev_dock_api.test.ts index d1bebecab..ed124052d 100644 --- a/libs/dev-dock/backend/src/dev_dock_api.test.ts +++ b/libs/dev-dock/backend/src/dev_dock_api.test.ts @@ -18,7 +18,14 @@ import { electionGeneral, } from '@votingworks/fixtures'; import { Server } from 'http'; -import { Api, useDevDockRouter } from './dev_dock_api'; +import { typedAs } from '@votingworks/basics'; +import { PrinterStatus } from '@votingworks/printing'; +import { + Api, + MachineType, + DEFAULT_PRINTERS, + useDevDockRouter, +} from './dev_dock_api'; const TEST_DEV_DOCK_FILE_PATH = '/tmp/dev-dock.test.json'; @@ -33,12 +40,12 @@ jest.mock('@votingworks/utils', () => { let server: Server; -function setup() { +function setup(machineType: MachineType = 'scan') { if (fs.existsSync(TEST_DEV_DOCK_FILE_PATH)) { fs.unlinkSync(TEST_DEV_DOCK_FILE_PATH); } const app = express(); - useDevDockRouter(app, express, TEST_DEV_DOCK_FILE_PATH); + useDevDockRouter(app, express, machineType, TEST_DEV_DOCK_FILE_PATH); server = app.listen(); const { port } = server.address() as AddressInfo; const baseUrl = `http://localhost:${port}/dock`; @@ -163,3 +170,40 @@ test('usb drive mock endpoints', async () => { await apiClient.clearUsbDrive(); await expect(apiClient.getUsbDriveStatus()).resolves.toEqual('removed'); }); + +test('machine type', async () => { + const { apiClient: apiClientMark } = setup('mark'); + expect(await apiClientMark.getMachineType()).toEqual('mark'); + + server.close(); + + const { apiClient: apiClientScan } = setup('scan'); + expect(await apiClientScan.getMachineType()).toEqual('scan'); +}); + +test.each(typedAs(['admin', 'scan']))( + 'printer for machine type %s', + async (machineType) => { + const { apiClient } = setup(machineType); + await expect(apiClient.getPrinterStatus()).resolves.toEqual( + typedAs({ + connected: false, + }) + ); + + await apiClient.connectPrinter(); + await expect(apiClient.getPrinterStatus()).resolves.toEqual( + typedAs({ + connected: true, + config: DEFAULT_PRINTERS[machineType]!, + }) + ); + + await apiClient.disconnectPrinter(); + await expect(apiClient.getPrinterStatus()).resolves.toEqual( + typedAs({ + connected: false, + }) + ); + } +); diff --git a/libs/dev-dock/backend/src/dev_dock_api.ts b/libs/dev-dock/backend/src/dev_dock_api.ts index 8bce44ff0..101b6b97a 100644 --- a/libs/dev-dock/backend/src/dev_dock_api.ts +++ b/libs/dev-dock/backend/src/dev_dock_api.ts @@ -13,6 +13,13 @@ import { BooleanEnvironmentVariableName, } from '@votingworks/utils'; import { getMockFileUsbDriveHandler } from '@votingworks/usb-drive'; +import { + BROTHER_THERMAL_PRINTER_CONFIG, + HP_LASER_PRINTER_CONFIG, + PrinterConfig, + PrinterStatus, + getMockFilePrinterHandler, +} from '@votingworks/printing'; import { execFile } from './utils'; export type DevDockUserRole = Exclude; @@ -22,6 +29,21 @@ export interface DevDockElectionInfo { path: string; } +export type MachineType = + | 'mark' + | 'mark-scan' + | 'scan' + | 'central-scan' + | 'admin'; + +export const DEFAULT_PRINTERS: Record> = { + admin: HP_LASER_PRINTER_CONFIG, + mark: undefined, // not yet implemented + scan: BROTHER_THERMAL_PRINTER_CONFIG, + 'mark-scan': undefined, + 'central-scan': undefined, +}; + // Convert paths relative to the VxSuite root to absolute paths function electionPathToAbsolute(path: string) { return isAbsolute(path) @@ -53,8 +75,9 @@ function readDevDockFileContents(devDockFilePath: string): DevDockFileContents { ) as DevDockFileContents; } -function buildApi(devDockFilePath: string) { +function buildApi(devDockFilePath: string, machineType: MachineType) { const usbHandler = getMockFileUsbDriveHandler(); + const printerHandler = getMockFilePrinterHandler(); return grout.createApi({ setElection(input: { path: string }): void { @@ -113,6 +136,24 @@ function buildApi(devDockFilePath: string) { clearUsbDrive(): void { usbHandler.clearData(); }, + + getPrinterStatus(): PrinterStatus { + return printerHandler.getPrinterStatus(); + }, + + connectPrinter(): void { + const config = DEFAULT_PRINTERS[machineType]; + assert(config); + printerHandler.connectPrinter(config); + }, + + disconnectPrinter(): void { + printerHandler.disconnectPrinter(); + }, + + getMachineType(): MachineType { + return machineType; + }, }); } @@ -127,6 +168,7 @@ export type Api = ReturnType; export function useDevDockRouter( app: Express.Application, express: typeof Express, + machineType: MachineType, /* istanbul ignore next */ devDockFilePath: string = DEV_DOCK_FILE_PATH ): void { @@ -139,7 +181,7 @@ export function useDevDockRouter( fs.writeFileSync(devDockFilePath, '{}'); } - const api = buildApi(devDockFilePath); + const api = buildApi(devDockFilePath, machineType); // Set a default election if one is not already set if (!api.getElection()) { diff --git a/libs/dev-dock/backend/tsconfig.build.json b/libs/dev-dock/backend/tsconfig.build.json index b501bfd9c..a244fe59c 100644 --- a/libs/dev-dock/backend/tsconfig.build.json +++ b/libs/dev-dock/backend/tsconfig.build.json @@ -15,6 +15,7 @@ { "path": "../../eslint-plugin-vx/tsconfig.build.json" }, { "path": "../../fixtures/tsconfig.build.json" }, { "path": "../../grout/tsconfig.build.json" }, + { "path": "../../printing/tsconfig.build.json" }, { "path": "../../test-utils/tsconfig.build.json" }, { "path": "../../types/tsconfig.build.json" }, { "path": "../../usb-drive/tsconfig.build.json" }, diff --git a/libs/dev-dock/backend/tsconfig.json b/libs/dev-dock/backend/tsconfig.json index e256af75f..f597a973d 100644 --- a/libs/dev-dock/backend/tsconfig.json +++ b/libs/dev-dock/backend/tsconfig.json @@ -16,6 +16,7 @@ { "path": "../../eslint-plugin-vx/tsconfig.build.json" }, { "path": "../../fixtures/tsconfig.build.json" }, { "path": "../../grout/tsconfig.build.json" }, + { "path": "../../printing/tsconfig.build.json" }, { "path": "../../test-utils/tsconfig.build.json" }, { "path": "../../types/tsconfig.build.json" }, { "path": "../../usb-drive/tsconfig.build.json" }, diff --git a/libs/dev-dock/frontend/src/dev_dock.test.tsx b/libs/dev-dock/frontend/src/dev_dock.test.tsx index a5540bf44..b23ca790f 100644 --- a/libs/dev-dock/frontend/src/dev_dock.test.tsx +++ b/libs/dev-dock/frontend/src/dev_dock.test.tsx @@ -54,6 +54,7 @@ let mockKiosk!: jest.Mocked; beforeEach(() => { mockApiClient = createMockClient(); + mockApiClient.getMachineType.expectCallWith().resolves('central-scan'); mockApiClient.getCardStatus.expectCallWith().resolves(noCardStatus); mockApiClient.getUsbDriveStatus.expectCallWith().resolves('removed'); mockApiClient.getElection.expectCallWith().resolves({ @@ -82,6 +83,7 @@ test('renders nothing if dev dock is disabled', () => { mockApiClient.getCardStatus.reset(); mockApiClient.getElection.reset(); mockApiClient.getUsbDriveStatus.reset(); + mockApiClient.getMachineType.reset(); featureFlagMock.disableFeatureFlag( BooleanEnvironmentVariableName.ENABLE_DEV_DOCK ); @@ -93,7 +95,7 @@ test('card mock controls', async () => { render(); // Card controls should enable once status loads - const systemAdminControl = screen.getByRole('button', { + const systemAdminControl = await screen.findByRole('button', { name: 'System Admin', }); await waitFor(() => { @@ -175,7 +177,7 @@ test('disabled card mock controls if card mocks are disabled', async () => { ); render(); - screen.getByText('Smart card mocks disabled'); + await screen.findByText('Smart card mocks disabled'); const systemAdminControl = screen.getByRole('button', { name: 'System Admin', }); @@ -196,7 +198,7 @@ test('disabled card mock controls if card mocks are disabled', async () => { test('election selector', async () => { render(); - const electionSelector = screen.getByRole('combobox'); + const electionSelector = await screen.findByRole('combobox'); await waitFor(() => { expect(electionSelector).toHaveValue( 'libs/fixtures/data/electionGeneral/election.json' @@ -225,7 +227,7 @@ test('election selector', async () => { test('USB drive controls', async () => { render(); - const usbDriveControl = screen.getByRole('button', { + const usbDriveControl = await screen.findByRole('button', { name: 'USB Drive', }); await waitFor(() => expect(usbDriveControl).toBeEnabled()); @@ -257,7 +259,7 @@ test('disabled USB drive controls if USB drive mocks are disabled', async () => ); render(); - screen.getByText('USB mock disabled'); + await screen.findByText('USB mock disabled'); const usbDriveControl = screen.getByRole('button', { name: 'USB Drive', }); @@ -274,7 +276,7 @@ test('disabled USB drive controls if USB drive mocks are disabled', async () => test('screenshot button', async () => { render(); - const screenshotButton = screen.getByRole('button', { + const screenshotButton = await screen.findByRole('button', { name: 'Capture Screenshot', }); @@ -290,3 +292,66 @@ test('screenshot button', async () => { expect(fileWriter.write).toHaveBeenCalledWith(screenshotBuffer); }); }); + +test('printer mock control', async () => { + featureFlagMock.enableFeatureFlag( + BooleanEnvironmentVariableName.USE_MOCK_PRINTER + ); + + mockApiClient.getMachineType.reset(); + mockApiClient.getMachineType.expectCallWith().resolves('admin'); + mockApiClient.getPrinterStatus.expectCallWith().resolves({ + connected: false, + }); + + render(); + const printerButton = await screen.findByRole('button', { + name: 'Printer', + }); + + mockApiClient.connectPrinter.expectCallWith().resolves(); + mockApiClient.getPrinterStatus.expectCallWith().resolves({ + connected: true, + config: { + label: 'mock', + vendorId: 0, + productId: 0, + baseDeviceUri: 'mock://', + ppd: 'mock.ppd', + }, + }); + userEvent.click(printerButton); + await waitFor(() => mockApiClient.assertComplete()); + + mockApiClient.disconnectPrinter.expectCallWith().resolves(); + mockApiClient.getPrinterStatus.expectCallWith().resolves({ + connected: false, + }); + userEvent.click(printerButton); + await waitFor(() => mockApiClient.assertComplete()); + + featureFlagMock.disableFeatureFlag( + BooleanEnvironmentVariableName.USE_MOCK_PRINTER + ); +}); + +test('printer mock when disabled', async () => { + featureFlagMock.disableFeatureFlag( + BooleanEnvironmentVariableName.USE_MOCK_PRINTER + ); + + mockApiClient.getMachineType.reset(); + mockApiClient.getMachineType.expectCallWith().resolves('admin'); + mockApiClient.getPrinterStatus.expectCallWith().resolves({ + connected: false, + }); + + render(); + const printerButton = await screen.findByRole('button', { + name: 'Printer', + }); + + expect(printerButton).toBeDisabled(); + screen.getByText('Printer mock disabled'); + userEvent.click(printerButton); +}); diff --git a/libs/dev-dock/frontend/src/dev_dock.tsx b/libs/dev-dock/frontend/src/dev_dock.tsx index ef6c5509c..e0c13f1e1 100644 --- a/libs/dev-dock/frontend/src/dev_dock.tsx +++ b/libs/dev-dock/frontend/src/dev_dock.tsx @@ -10,13 +10,18 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import styled from 'styled-components'; import * as grout from '@votingworks/grout'; import { assert, assertDefined, uniqueBy } from '@votingworks/basics'; -import type { Api, DevDockUserRole } from '@votingworks/dev-dock-backend'; +import type { + Api, + DevDockUserRole, + MachineType, +} from '@votingworks/dev-dock-backend'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faCamera, faCaretDown, faCaretUp, faCircleDown, + faPrint, } from '@fortawesome/free-solid-svg-icons'; import { isFeatureFlagEnabled, @@ -453,6 +458,79 @@ function ScreenshotControls({ ); } +const PrinterButton = styled.button<{ isConnected: boolean }>` + position: relative; + background-color: white; + width: 80px; + height: 80px; + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 5px; + border: ${(props) => + props.isConnected + ? `4px solid ${Colors.ACTIVE}` + : `1px solid ${Colors.BORDER}`}; + color: ${(props) => (props.isConnected ? Colors.ACTIVE : Colors.TEXT)}; + &:disabled { + color: ${Colors.DISABLED}; + border-color: ${Colors.DISABLED}; + } +`; + +const APPS_WITH_PRINTER: MachineType[] = ['admin', 'scan']; + +function PrinterMockControl() { + const queryClient = useQueryClient(); + const apiClient = useApiClient(); + const getPrinterStatusQuery = useQuery(['getPrinterStatus'], () => + apiClient.getPrinterStatus() + ); + const connectPrinterMutation = useMutation(apiClient.connectPrinter, { + onSuccess: async () => + await queryClient.invalidateQueries(['getPrinterStatus']), + }); + const disconnectPrinterMutation = useMutation(apiClient.disconnectPrinter, { + onSuccess: async () => + await queryClient.invalidateQueries(['getPrinterStatus']), + }); + + const status = getPrinterStatusQuery.data ?? undefined; + + function onPrinterClick() { + if (status?.connected) { + disconnectPrinterMutation.mutate(); + } else { + connectPrinterMutation.mutate(); + } + } + + const isFeatureEnabled = isFeatureFlagEnabled( + BooleanEnvironmentVariableName.USE_MOCK_PRINTER + ); + + const disabled = !isFeatureEnabled || !getPrinterStatusQuery.isSuccess; + + const isConnected = status?.connected === true; + return ( + + + {!isFeatureEnabled && ( + +

Printer mock disabled

+
+ )} +
+ ); +} + const Container = styled.div` position: fixed; top: 0; @@ -539,6 +617,13 @@ function DevDock() { const [isOpen, setIsOpen] = useState(true); const containerRef = useRef(null); + const apiClient = useApiClient(); + + const getMachineTypeQuery = useQuery( + ['getMachineType'], + async () => (await apiClient.getMachineType()) ?? null + ); + function onKeyDown(event: KeyboardEvent): void { if (event.key === 'd' && event.metaKey) { setIsOpen((previousIsOpen) => !previousIsOpen); @@ -553,6 +638,9 @@ function DevDock() { return () => window.removeEventListener('keydown', onKeyDown); }, []); + if (!getMachineTypeQuery.isSuccess) return null; + const machineType = getMachineTypeQuery.data; + return ( @@ -570,6 +658,7 @@ function DevDock() { + {APPS_WITH_PRINTER.includes(machineType) && } diff --git a/libs/printing/.gitignore b/libs/printing/.gitignore new file mode 100644 index 000000000..359372cc4 --- /dev/null +++ b/libs/printing/.gitignore @@ -0,0 +1 @@ +/dev-workspace \ No newline at end of file diff --git a/libs/printing/src/printer/mocks/file_printer.test.ts b/libs/printing/src/printer/mocks/file_printer.test.ts index 3a93bc288..7e1a3b525 100644 --- a/libs/printing/src/printer/mocks/file_printer.test.ts +++ b/libs/printing/src/printer/mocks/file_printer.test.ts @@ -1,12 +1,13 @@ import { Buffer } from 'buffer'; import { existsSync, readFileSync, readdirSync } from 'fs'; -import { sleep } from '@votingworks/basics'; +import { sleep, typedAs } from '@votingworks/basics'; import { DEFAULT_MOCK_USB_DRIVE_DIR, MockFilePrinter, getMockFilePrinterHandler, } from './file_printer'; -import { HP_LASER_PRINTER_CONFIG } from '..'; +import { HP_LASER_PRINTER_CONFIG } from '../supported'; +import { PrinterStatus } from '../types'; beforeEach(() => { getMockFilePrinterHandler().cleanup(); @@ -18,16 +19,26 @@ test('mock file printer', async () => { const filePrinter = new MockFilePrinter(); const filePrinterHandler = getMockFilePrinterHandler(); - expect(await filePrinter.status()).toEqual({ connected: false }); + expect(await filePrinter.status()).toEqual( + typedAs({ connected: false }) + ); + expect(filePrinterHandler.getPrinterStatus()).toEqual( + await filePrinter.status() + ); await expect( filePrinter.print({ data: Buffer.from('test') }) ).rejects.toThrow(); filePrinterHandler.connectPrinter(HP_LASER_PRINTER_CONFIG); - expect(await filePrinter.status()).toEqual({ - connected: true, - config: HP_LASER_PRINTER_CONFIG, - }); + expect(await filePrinter.status()).toEqual( + typedAs({ + connected: true, + config: HP_LASER_PRINTER_CONFIG, + }) + ); + expect(filePrinterHandler.getPrinterStatus()).toEqual( + await filePrinter.status() + ); await filePrinter.print({ data: Buffer.from('print-1') }); @@ -53,7 +64,12 @@ test('mock file printer', async () => { ); filePrinterHandler.disconnectPrinter(); - expect(await filePrinter.status()).toEqual({ connected: false }); + expect(await filePrinter.status()).toEqual( + typedAs({ connected: false }) + ); + expect(filePrinterHandler.getPrinterStatus()).toEqual( + await filePrinter.status() + ); filePrinterHandler.cleanup(); expect(existsSync(DEFAULT_MOCK_USB_DRIVE_DIR)).toEqual(false); diff --git a/libs/printing/src/printer/mocks/file_printer.ts b/libs/printing/src/printer/mocks/file_printer.ts index e8c770536..fe27efadf 100644 --- a/libs/printing/src/printer/mocks/file_printer.ts +++ b/libs/printing/src/printer/mocks/file_printer.ts @@ -16,8 +16,14 @@ import { PrintProps, Printer, PrinterConfig, PrinterStatus } from '../types'; export const MOCK_PRINTER_STATE_FILENAME = 'state.json'; export const MOCK_PRINTER_OUTPUT_DIRNAME = 'prints'; export const DEFAULT_MOCK_USB_DRIVE_DIR = '/tmp/mock-printer'; +export const DEV_MOCK_PRINTER_DIR = join(__dirname, '../../../dev-workspace'); function getMockPrinterPath(): string { + // istanbul ignore next + if (process.env.NODE_ENV === 'development') { + return DEV_MOCK_PRINTER_DIR; + } + return DEFAULT_MOCK_USB_DRIVE_DIR; } @@ -119,6 +125,7 @@ export class MockFilePrinter implements Printer { interface MockFilePrinterHandler { connectPrinter: (config: PrinterConfig) => void; disconnectPrinter: () => void; + getPrinterStatus(): PrinterStatus; getDataPath: () => string; getLastPrintPath: () => Optional; cleanup: () => void; @@ -137,6 +144,7 @@ export function getMockFilePrinterHandler(): MockFilePrinterHandler { connected: false, }); }, + getPrinterStatus: () => readFromMockFile(), getDataPath: getMockPrinterOutputPath, getLastPrintPath() { const printPaths = readdirSync(getMockPrinterOutputPath(), { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a6f5e469..bf0cd92d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3803,6 +3803,9 @@ importers: '@votingworks/grout': specifier: workspace:* version: link:../../grout + '@votingworks/printing': + specifier: workspace:* + version: link:../../printing '@votingworks/types': specifier: workspace:* version: link:../../types