Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(kiosk-browser): remove uses of non-trivial APIs #5637

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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