Skip to content

Commit

Permalink
chore(kiosk-browser): remove uses of non-trivial APIs (#5637)
Browse files Browse the repository at this point in the history
  • Loading branch information
eventualbuddha authored Dec 2, 2024
1 parent 4001b2d commit 5e8e3f3
Show file tree
Hide file tree
Showing 11 changed files with 58 additions and 273 deletions.
126 changes: 0 additions & 126 deletions apps/admin/frontend/src/components/save_backend_file_modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<SaveBackendFileModal
saveFileStatus="idle"
saveFile={saveFile}
saveFileResult={undefined}
resetSaveFileResult={jest.fn()}
onClose={jest.fn()}
defaultRelativePath="batch-export.csv"
fileTypeTitle="Batch Export"
fileType="batch export"
/>,
{
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());

Expand Down Expand Up @@ -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(
<SaveBackendFileModal
saveFileStatus="idle"
saveFile={saveFile}
saveFileResult={undefined}
resetSaveFileResult={jest.fn()}
onClose={jest.fn()}
defaultRelativePath="exports/batch-export.csv"
fileTypeTitle="Batch Export"
fileType="batch export"
/>,
{
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(
<SaveBackendFileModal
Expand Down Expand Up @@ -256,42 +169,3 @@ test('shows error screen if saving file failed on backend', () => {
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(
<SaveBackendFileModal
saveFileStatus="idle"
saveFile={saveFile}
saveFileResult={undefined}
resetSaveFileResult={jest.fn()}
onClose={jest.fn()}
defaultRelativePath="exports/batch-export.csv"
fileTypeTitle="Batch Export"
fileType="batch export"
/>,
{
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');
});
52 changes: 1 addition & 51 deletions apps/admin/frontend/src/components/save_backend_file_modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Button onPress={useSaveDialog} disabled={disabled}>
Save As…
</Button>
);
}

export interface SaveBackendFileModalProps {
saveFileStatus: MutationStatus;
saveFile: (input: { path: string }) => void;
Expand Down Expand Up @@ -90,21 +65,7 @@ export function SaveBackendFileModal({
</P>
}
onOverlayClick={onClose}
actions={
<React.Fragment>
{window.kiosk && process.env.NODE_ENV === 'development' && (
<SaveAsButton
onSave={(path) => saveFile({ path })}
options={{
// Provide a file name, but allow the system dialog to use its
// default starting directory.
defaultPath: basename(defaultRelativePath),
}}
/>
)}
<Button onPress={onClose}>Cancel</Button>
</React.Fragment>
}
actions={<Button onPress={onClose}>Cancel</Button>}
/>
);
case 'mounted': {
Expand Down Expand Up @@ -135,17 +96,6 @@ export function SaveBackendFileModal({
Save
</Button>
<Button onPress={onClose}>Cancel</Button>
<SaveAsButton
onSave={(path) => 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}
/>
</React.Fragment>
}
/>
Expand Down
55 changes: 1 addition & 54 deletions libs/@types/kiosk-browser/kiosk-browser.d.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
declare namespace KioskBrowser {
export interface SaveAsOptions {
title?: string;
defaultPath?: string;
buttonLabel?: string;
filters?: FileFilter[];
}

// Copied from Electron.OpenDialogOptions
export interface OpenDialogOptions {
title?: string;
Expand All @@ -25,63 +18,17 @@ declare namespace KioskBrowser {
>;
}

// 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<void>;

/**
* Finishes writing to the file and closes it. Subsequent calls to `write`
* will fail. Resolves when the file is successfully closed.
*/
end(): Promise<void>;

filename: string;
}

export interface Kiosk {
log(message: string): Promise<void>;

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<FileWriter | undefined>;

showOpenDialog(options?: OpenDialogOptions): Promise<{
canceled: boolean;
filePaths: string[];
}>;

showSaveDialog(options?: SaveDialogOptions): Promise<{
canceled: boolean;
filePath?: string;
}>;

captureScreenshot(): Promise<Buffer>;
captureScreenshot(): Promise<Uint8Array>;
}
}

Expand Down
18 changes: 17 additions & 1 deletion libs/dev-dock/backend/src/dev_dock_api.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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,
PrinterStatus,
safeParseElectionDefinition,
UserRole,
} from '@votingworks/types';
import { isAbsolute, join } from 'node:path';
import {
CardStatus,
readFromMockFile as readFromCardMockFile,
Expand All @@ -27,6 +28,7 @@ import {
HP_LASER_PRINTER_CONFIG,
getMockFilePrinterHandler,
} from '@votingworks/printing';
import { writeFile } from 'node:fs/promises';
import { execFile } from './utils';

export type DevDockUserRole = Exclude<UserRole, 'cardless_voter'>;
Expand Down Expand Up @@ -168,6 +170,20 @@ function buildApi(devDockFilePath: string, machineType: MachineType) {
usbHandler.clearData();
},

async saveScreenshotForApp({
appName,
screenshot,
}: {
appName: string;
screenshot: Uint8Array;
}): Promise<string> {
assert(/^[a-z0-9]+$/i.test(appName));
const downloadsPath = join(homedir(), 'Downloads');
const fileName = `Screenshot-${appName}-${new Date().toISOString()}.png`;
await writeFile(join(downloadsPath, fileName), screenshot);
return fileName;
},

getPrinterStatus(): PrinterStatus {
return printerHandler.getPrinterStatus();
},
Expand Down
18 changes: 9 additions & 9 deletions libs/dev-dock/frontend/src/dev_dock.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,7 +11,6 @@ import {
mockElectionManagerUser,
mockPollWorkerUser,
mockKiosk,
mockFileWriter,
} from '@votingworks/test-utils';
import { CardStatus } from '@votingworks/auth';
import { DevDock } from './dev_dock';
Expand Down Expand Up @@ -293,16 +291,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.saveScreenshotForApp
.expectCallWith({ appName: 'VxAdmin', screenshot: Uint8Array.of() })
.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.'
);
});
});

Expand Down
Loading

0 comments on commit 5e8e3f3

Please sign in to comment.