diff --git a/apps/admin/frontend/src/components/save_backend_file_modal.test.tsx b/apps/admin/frontend/src/components/save_backend_file_modal.test.tsx index d7a53184cd..025fe4bbb8 100644 --- a/apps/admin/frontend/src/components/save_backend_file_modal.test.tsx +++ b/apps/admin/frontend/src/components/save_backend_file_modal.test.tsx @@ -56,52 +56,6 @@ test('render no usb found screen when there is not a valid mounted usb drive', ( } }); -test('has development shortcut to export file without USB drive', async () => { - const mockShowSaveDialog = jest - .fn() - .mockResolvedValue({ filePath: '/user/batch-export.csv' }); - kiosk.showSaveDialog = mockShowSaveDialog; - - const originalEnv: NodeJS.ProcessEnv = { ...process.env }; - process.env = { - ...originalEnv, - NODE_ENV: 'development', - }; - - const saveFile = jest.fn().mockResolvedValue(ok()); - - renderInAppContext( - , - { - usbDriveStatus: mockUsbDriveStatus('no_drive'), - apiMock, - } - ); - - // TODO: remove when USB status comes from backend. currently, allows - // component to set the usb drive path in useEffect - await advancePromises(); - - userEvent.click(screen.getButton('Save As…')); - expect(mockShowSaveDialog).toHaveBeenCalledWith({ - defaultPath: 'batch-export.csv', - }); - await waitFor(() => { - expect(saveFile).toHaveBeenCalledWith({ path: '/user/batch-export.csv' }); - }); - - process.env = originalEnv; -}); - test('happy usb path - save to default location', async () => { const saveFile = jest.fn().mockResolvedValue(ok()); @@ -134,47 +88,6 @@ test('happy usb path - save to default location', async () => { }); }); -test('happy usb path - save as', async () => { - const mockShowSaveDialog = jest.fn().mockResolvedValue({ - filePath: 'test-mount-point/batch-export.csv', - }); - kiosk.showSaveDialog = mockShowSaveDialog; - - const saveFile = jest.fn().mockResolvedValue(ok()); - - renderInAppContext( - , - { - usbDriveStatus: mockUsbDriveStatus('mounted'), - apiMock, - } - ); - await screen.findByText('Save Batch Export'); - - // TODO: remove when USB status comes from backend. currently, allows - // component to set the usb drive path in useEffect - await advancePromises(); - - userEvent.click(screen.getButton('Save As…')); - expect(mockShowSaveDialog).toHaveBeenCalledWith({ - defaultPath: 'test-mount-point/batch-export.csv', - }); - await waitFor(() => { - expect(saveFile).toHaveBeenCalledWith({ - path: 'test-mount-point/batch-export.csv', - }); - }); -}); - test('renders saving modal when mutation is loading', () => { renderInAppContext( { screen.getByText('Batch Export Not Saved'); screen.getByText('Failed to save batch export. Permission denied.'); }); - -test('can cancel save dialog', async () => { - const mockShowSaveDialog = jest.fn().mockResolvedValue({ canceled: true }); - kiosk.showSaveDialog = mockShowSaveDialog; - - const saveFile = jest.fn().mockResolvedValue(ok()); - - renderInAppContext( - , - { - usbDriveStatus: mockUsbDriveStatus('mounted'), - apiMock, - } - ); - await screen.findByText('Save Batch Export'); - // TODO: remove when USB status comes from backend. currently, allows - // component to set the usb drive path in useEffect - await advancePromises(); - userEvent.click(screen.getButton('Save As…')); - expect(mockShowSaveDialog).toHaveBeenCalledWith({ - defaultPath: 'test-mount-point/batch-export.csv', - }); - - // because the save dialog is not part of the UI, we cannot wait for its disappearance, - // but we need to allow the save as button to settle - await advancePromises(); - - expect(saveFile).not.toHaveBeenCalled(); - screen.getByText('Save Batch Export'); -}); diff --git a/apps/admin/frontend/src/components/save_backend_file_modal.tsx b/apps/admin/frontend/src/components/save_backend_file_modal.tsx index 2ed0ba045e..e84767ca67 100644 --- a/apps/admin/frontend/src/components/save_backend_file_modal.tsx +++ b/apps/admin/frontend/src/components/save_backend_file_modal.tsx @@ -13,31 +13,6 @@ import { MutationStatus } from '@tanstack/react-query'; import { AppContext } from '../contexts/app_context'; import { Loading } from './loading'; -interface SaveAsButtonProps { - onSave: (location: string) => void; - disabled?: boolean; - options?: KioskBrowser.SaveDialogOptions; -} - -function SaveAsButton({ - onSave, - disabled, - options, -}: SaveAsButtonProps): JSX.Element { - async function useSaveDialog() { - assert(window.kiosk); - const { filePath } = await window.kiosk.showSaveDialog(options); - if (filePath) { - onSave(filePath); - } - } - return ( - - ); -} - export interface SaveBackendFileModalProps { saveFileStatus: MutationStatus; saveFile: (input: { path: string }) => void; @@ -90,21 +65,7 @@ export function SaveBackendFileModal({

} onOverlayClick={onClose} - actions={ - - {window.kiosk && process.env.NODE_ENV === 'development' && ( - saveFile({ path })} - options={{ - // Provide a file name, but allow the system dialog to use its - // default starting directory. - defaultPath: basename(defaultRelativePath), - }} - /> - )} - - - } + actions={} /> ); case 'mounted': { @@ -135,17 +96,6 @@ export function SaveBackendFileModal({ Save - saveFile({ path })} - options={{ - // Provide a file name and default to the USB drive's root directory. - defaultPath: join( - usbDriveStatus.mountPoint, - basename(defaultRelativePath) - ), - }} - disabled={!window.kiosk} - /> } /> diff --git a/apps/admin/frontend/src/screens/unconfigured_screen.test.tsx b/apps/admin/frontend/src/screens/unconfigured_screen.test.tsx index 1dbf53b7ee..3cce9d94f4 100644 --- a/apps/admin/frontend/src/screens/unconfigured_screen.test.tsx +++ b/apps/admin/frontend/src/screens/unconfigured_screen.test.tsx @@ -1,5 +1,4 @@ import userEvent from '@testing-library/user-event'; -import { mockKiosk } from '@votingworks/test-utils'; import { err } from '@votingworks/basics'; import { mockUsbDriveStatus } from '@votingworks/ui'; import { renderInAppContext } from '../../test/render_in_app_context'; @@ -40,7 +39,6 @@ test('handles no election packages on USB drive', async () => { await screen.findByRole('heading', { name: 'Election' }); screen.getByText('No election packages found on the inserted USB drive.'); - screen.getButton('Select Other File...'); }); test('configures from election packages on USB drive', async () => { @@ -88,27 +86,6 @@ test('configures from election packages on USB drive', async () => { await waitFor(() => apiMock.assertComplete()); }); -test('configures from selected file', async () => { - const kiosk = mockKiosk(); - window.kiosk = kiosk; - kiosk.showOpenDialog.mockResolvedValueOnce({ - canceled: false, - filePaths: ['/path/to/election-package.zip'], - }); - - apiMock.expectListPotentialElectionPackagesOnUsbDrive([]); - renderInAppContext(, { - apiMock, - usbDriveStatus: mockUsbDriveStatus('mounted'), - }); - - await screen.findByRole('heading', { name: 'Election' }); - - apiMock.expectConfigure('/path/to/election-package.zip'); - userEvent.click(screen.getButton('Select Other File...')); - await waitFor(() => apiMock.assertComplete()); -}); - test('shows configuration error', async () => { apiMock.expectListPotentialElectionPackagesOnUsbDrive([ { diff --git a/apps/admin/frontend/src/screens/unconfigured_screen.tsx b/apps/admin/frontend/src/screens/unconfigured_screen.tsx index 0758ae6b8b..0cde994ce9 100644 --- a/apps/admin/frontend/src/screens/unconfigured_screen.tsx +++ b/apps/admin/frontend/src/screens/unconfigured_screen.tsx @@ -2,7 +2,6 @@ import React, { useContext } from 'react'; import styled from 'styled-components'; import { DateTime } from 'luxon'; import { - Button, Card, FullScreenMessage, H2, @@ -11,7 +10,7 @@ import { UsbDriveImage, } from '@votingworks/ui'; import type { FileSystemEntry } from '@votingworks/fs'; -import { assertDefined, throwIllegalValue } from '@votingworks/basics'; +import { throwIllegalValue } from '@votingworks/basics'; import { Loading } from '../components/loading'; import { NavigationScreen } from '../components/navigation_screen'; import { configure, listPotentialElectionPackagesOnUsbDrive } from '../api'; @@ -37,18 +36,6 @@ function SelectElectionPackage({ }): JSX.Element { const configureMutation = configure.useMutation(); - async function onSelectOtherFile() { - const dialogResult = await assertDefined(window.kiosk).showOpenDialog({ - properties: ['openFile'], - filters: [{ name: '', extensions: ['zip', 'json'] }], - }); - if (dialogResult.canceled) return; - const selectedPath = dialogResult.filePaths[0]; - if (selectedPath) { - configureMutation.mutate({ electionFilePath: selectedPath }); - } - } - const configureError = configureMutation.data?.err(); return ( @@ -111,14 +98,6 @@ function SelectElectionPackage({ )} -
- -
); diff --git a/libs/@types/kiosk-browser/kiosk-browser.d.ts b/libs/@types/kiosk-browser/kiosk-browser.d.ts index eb6f12ad50..8e25e19877 100644 --- a/libs/@types/kiosk-browser/kiosk-browser.d.ts +++ b/libs/@types/kiosk-browser/kiosk-browser.d.ts @@ -1,87 +1,7 @@ declare namespace KioskBrowser { - export interface SaveAsOptions { - title?: string; - defaultPath?: string; - buttonLabel?: string; - filters?: FileFilter[]; - } - - // Copied from Electron.OpenDialogOptions - export interface OpenDialogOptions { - title?: string; - defaultPath?: string; - /** - * Custom label for the confirmation button, when left empty the default label will - * be used. - */ - buttonLabel?: string; - filters?: FileFilter[]; - /** - * Contains which features the dialog should use. The following values are - * supported: - */ - properties?: Array< - 'openFile' | 'openDirectory' | 'multiSelections' | 'showHiddenFiles' - >; - } - - // Copied from Electron.SaveDialogOptions, with Linux relevant options only - export interface SaveDialogOptions { - /** - * The dialog title. Cannot be displayed on some `Linux` desktop environments. - */ - title?: string; - /** - * Absolute directory path, absolute file path, or file name to use by default. - */ - defaultPath?: string; - /** - * Custom label for the confirmation button, when left empty the default label will - * be used. - */ - buttonLabel?: string; - filters?: FileFilter[]; - properties?: Array<'showHiddenFiles' | 'showOverwriteConfirmation'>; - } - - export interface FileWriter { - /** - * Writes a chunk to the file. May be called multiple times. Data will be - * written in the order of calls to `write`. - */ - write(data: Uint8Array | string): Promise; - - /** - * Finishes writing to the file and closes it. Subsequent calls to `write` - * will fail. Resolves when the file is successfully closed. - */ - end(): Promise; - - filename: string; - } - export interface Kiosk { log(message: string): Promise; - quit(): void; - - /** - * Opens a Save Dialog to allow the user to choose a destination for a file. - * Once chosen, resolves with a handle to the file to write data to it. - */ - saveAs(options?: SaveAsOptions): Promise; - - showOpenDialog(options?: OpenDialogOptions): Promise<{ - canceled: boolean; - filePaths: string[]; - }>; - - showSaveDialog(options?: SaveDialogOptions): Promise<{ - canceled: boolean; - filePath?: string; - }>; - - captureScreenshot(): Promise; } } diff --git a/libs/dev-dock/backend/src/dev_dock_api.ts b/libs/dev-dock/backend/src/dev_dock_api.ts index 8a7a083833..bc1ebfa1fb 100644 --- a/libs/dev-dock/backend/src/dev_dock_api.ts +++ b/libs/dev-dock/backend/src/dev_dock_api.ts @@ -1,6 +1,8 @@ import type Express from 'express'; import * as grout from '@votingworks/grout'; import * as fs from 'node:fs'; +import { homedir } from 'node:os'; +import { isAbsolute, join } from 'node:path'; import { Optional, assert } from '@votingworks/basics'; import { PrinterConfig, @@ -8,7 +10,6 @@ import { safeParseElectionDefinition, UserRole, } from '@votingworks/types'; -import { isAbsolute, join } from 'node:path'; import { CardStatus, readFromMockFile as readFromCardMockFile, @@ -168,6 +169,14 @@ function buildApi(devDockFilePath: string, machineType: MachineType) { usbHandler.clearData(); }, + async captureScreenshot({ appName }: { appName: string }): Promise { + assert(/^[a-z0-9]+$/i.test(appName)); + const downloadsPath = join(homedir(), 'Downloads'); + const fileName = `Screenshot-${appName}-${new Date().toISOString()}.png`; + await execFile('gnome-screenshot', ['-f', join(downloadsPath, fileName)]); + return fileName; + }, + getPrinterStatus(): PrinterStatus { return printerHandler.getPrinterStatus(); }, diff --git a/libs/dev-dock/frontend/package.json b/libs/dev-dock/frontend/package.json index 9d300c8378..7580c5f5e3 100644 --- a/libs/dev-dock/frontend/package.json +++ b/libs/dev-dock/frontend/package.json @@ -36,7 +36,6 @@ "@testing-library/user-event": "^13.5.0", "@types/express": "4.17.14", "@types/jest": "^29.5.3", - "@types/kiosk-browser": "workspace:*", "@types/node": "20.16.0", "@types/react": "18.3.3", "@types/styled-components": "^5.1.26", diff --git a/libs/dev-dock/frontend/src/dev_dock.test.tsx b/libs/dev-dock/frontend/src/dev_dock.test.tsx index 305dcd12b1..3fe2441b7e 100644 --- a/libs/dev-dock/frontend/src/dev_dock.test.tsx +++ b/libs/dev-dock/frontend/src/dev_dock.test.tsx @@ -1,5 +1,4 @@ import { render, screen, waitFor, within } from '@testing-library/react'; -import { Buffer } from 'node:buffer'; import userEvent from '@testing-library/user-event'; import { createMockClient, MockClient } from '@votingworks/grout-test-utils'; import type { Api } from '@votingworks/dev-dock-backend'; @@ -11,8 +10,6 @@ import { mockSystemAdministratorUser, mockElectionManagerUser, mockPollWorkerUser, - mockKiosk, - mockFileWriter, } from '@votingworks/test-utils'; import { CardStatus } from '@votingworks/auth'; import { DevDock } from './dev_dock'; @@ -48,7 +45,6 @@ jest.mock('@votingworks/utils', () => ({ })); let mockApiClient: MockClient; -let kiosk!: jest.Mocked; beforeEach(() => { mockApiClient = createMockClient(); @@ -82,8 +78,6 @@ beforeEach(() => { featureFlagMock.enableFeatureFlag( BooleanEnvironmentVariableName.USE_MOCK_USB_DRIVE ); - kiosk = mockKiosk(); - window.kiosk = kiosk; }); afterEach(() => { @@ -293,16 +287,18 @@ test('screenshot button', async () => { name: 'Capture Screenshot', }); - const screenshotBuffer = Buffer.of(); - const fileWriter = mockFileWriter(); - kiosk.captureScreenshot.mockResolvedValueOnce(screenshotBuffer); - kiosk.saveAs.mockResolvedValueOnce(fileWriter); + jest.spyOn(window, 'alert').mockImplementation(() => {}); + document.title = 'VotingWorks VxAdmin'; + mockApiClient.captureScreenshot + .expectCallWith({ appName: 'VxAdmin' }) + .resolves('Screenshot-VxAdmin-2024-11-25-00:00:00.000Z.png'); userEvent.click(screenshotButton); await waitFor(() => { - expect(kiosk.captureScreenshot).toHaveBeenCalled(); - expect(kiosk.saveAs).toHaveBeenCalled(); - expect(fileWriter.write).toHaveBeenCalledWith(screenshotBuffer); + mockApiClient.assertComplete(); + expect(window.alert).toHaveBeenCalledWith( + 'Screenshot saved as Screenshot-VxAdmin-2024-11-25-00:00:00.000Z.png in the Downloads folder.' + ); }); }); diff --git a/libs/dev-dock/frontend/src/dev_dock.tsx b/libs/dev-dock/frontend/src/dev_dock.tsx index 4b87c293c4..e46f261b35 100644 --- a/libs/dev-dock/frontend/src/dev_dock.tsx +++ b/libs/dev-dock/frontend/src/dev_dock.tsx @@ -11,7 +11,6 @@ import styled from 'styled-components'; import * as grout from '@votingworks/grout'; import { assert, - assertDefined, sleep, throwIllegalValue, uniqueBy, @@ -83,22 +82,11 @@ function ElectionControl(): JSX.Element | null { const selectedElection = getElectionQuery.data; - async function onSelectElection( + function onSelectElection( event: React.ChangeEvent ) { const path = event.target.value; - if (path === 'Pick from file...') { - const dialogResult = await assertDefined(window.kiosk).showOpenDialog({ - properties: ['openFile'], - }); - if (dialogResult.canceled) return; - const selectedPath = dialogResult.filePaths[0]; - if (selectedPath) { - setElectionMutation.mutate({ path: selectedPath }); - } - } else { - setElectionMutation.mutate({ path }); - } + setElectionMutation.mutate({ path }); } const elections = uniqueBy( @@ -116,7 +104,6 @@ function ElectionControl(): JSX.Element | null { {election.title} - {election.path} ))} - {window.kiosk && } ); } @@ -406,6 +393,9 @@ function ScreenshotControls({ }: { containerRef: RefObject; }) { + const apiClient = useApiClient(); + const captureScreenshotMutation = useMutation(apiClient.captureScreenshot); + async function captureScreenshot() { // Use a ref to the dock container to momentarily hide it during the // screenshot. @@ -414,25 +404,22 @@ function ScreenshotControls({ containerRef.current.style.visibility = 'hidden'; await sleep(500); - assert(window.kiosk); - const screenshotData = await window.kiosk.captureScreenshot(); + // "VotingWorks VxAdmin" -> "VxAdmin" + const appName = document.title.replace('VotingWorks', '').trim(); + const fileName = await captureScreenshotMutation.mutateAsync({ appName }); // eslint-disable-next-line no-param-reassign containerRef.current.style.visibility = 'visible'; - // "VotingWorks VxAdmin" -> "VxAdmin" - const appName = document.title.replace('VotingWorks', '').trim(); - const fileName = `Screenshot-${appName}-${new Date().toISOString()}.png`; - const saveFile = await window.kiosk.saveAs({ - defaultPath: fileName, - }); - await saveFile?.write(screenshotData); + if (fileName) { + // eslint-disable-next-line no-alert + alert(`Screenshot saved as ${fileName} in the Downloads folder.`); + } } return ( diff --git a/libs/test-utils/src/index.ts b/libs/test-utils/src/index.ts index 7a71a18d83..11b6a1bee0 100644 --- a/libs/test-utils/src/index.ts +++ b/libs/test-utils/src/index.ts @@ -5,7 +5,6 @@ export * from './backend_wait_for'; export * from './child_process'; export * from './compressed_tallies'; export * from './console'; -export * from './mock_file_writer'; export * from './mock_kiosk'; export * from './mock_use_audio_controls'; export * from './has_text_across_elements'; diff --git a/libs/test-utils/src/mock_file_writer.ts b/libs/test-utils/src/mock_file_writer.ts deleted file mode 100644 index b1512db101..0000000000 --- a/libs/test-utils/src/mock_file_writer.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type Chunk = Parameters[0]; - -export interface MockFileWriter extends KioskBrowser.FileWriter { - chunks: readonly Chunk[]; -} - -export function mockFileWriter(): jest.Mocked { - const chunks: Chunk[] = []; - - return { - filename: '/mock/file', - write: jest.fn().mockImplementation((chunk) => { - chunks.push(chunk); - }), - end: jest.fn().mockResolvedValue(undefined), - chunks, - }; -} diff --git a/libs/test-utils/src/mock_kiosk.ts b/libs/test-utils/src/mock_kiosk.ts index 2fb3f37868..1f58c23819 100644 --- a/libs/test-utils/src/mock_kiosk.ts +++ b/libs/test-utils/src/mock_kiosk.ts @@ -1,5 +1,3 @@ -import { Buffer } from 'node:buffer'; - export type MockKiosk = jest.Mocked; /** @@ -8,10 +6,6 @@ export type MockKiosk = jest.Mocked; export function mockKiosk(): MockKiosk { return { quit: jest.fn(), - saveAs: jest.fn().mockResolvedValue(undefined), log: jest.fn(), - captureScreenshot: jest.fn().mockResolvedValue(Buffer.of()), - showOpenDialog: jest.fn(), - showSaveDialog: jest.fn(), }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3735161a10..53671a5feb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3844,9 +3844,6 @@ importers: '@types/jest': specifier: ^29.5.3 version: 29.5.3 - '@types/kiosk-browser': - specifier: workspace:* - version: link:../../@types/kiosk-browser '@types/node': specifier: 20.16.0 version: 20.16.0