Files
onenote-tools/src/App.test.tsx

575 lines
17 KiB
TypeScript

import { fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { OneNoteApplication } from './App.js';
import type {
OneNotePackageDto,
OneNoteSectionDto,
} from './onenote/model/dto.js';
import {
MAX_SOURCE_FILE_BYTES,
sourceKindFromFileName,
} from './source-file.js';
import {
OneNoteWorkerClient,
type WorkerPort,
} from './worker/onenote.client.js';
import type {
WorkerRequest,
WorkerResponse,
} from './worker/worker-protocol.js';
const section: OneNoteSectionDto = {
format: 'one',
sourceName: 'Section.one',
sectionName: 'Project notes',
pages: [
{
id: 'page-1',
title: 'First meeting',
text: 'This is the complete extracted page text.',
textPreview: 'This is the complete extracted page text.',
createdAt: '2024-01-02T10:00:00.000Z',
},
],
diagnostics: [
{
severity: 'warning',
code: 'UNSUPPORTED_IMAGE',
message: 'An image was not rendered.',
recoverable: true,
},
],
parserInfo: {
implementation: 'typescript',
supportedVariant: 'desktop-revision-store',
referenceRevision: 'test',
},
};
class SuccessfulParserWorker implements WorkerPort {
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null;
onerror: ((event: ErrorEvent) => void) | null = null;
terminated = false;
lastMessage: WorkerRequest | undefined;
readonly messages: WorkerRequest[] = [];
lastTransfer: Transferable[] = [];
constructor(
private readonly packageResult?: OneNotePackageDto,
private readonly resourcePage = false
) {}
postMessage(message: WorkerRequest, transfer: Transferable[] = []): void {
this.lastMessage = message;
this.messages.push(message);
this.lastTransfer = transfer;
queueMicrotask(() => {
if (message.type === 'parse-one') {
this.respond({
type: 'section',
requestId: message.requestId,
sessionId: 'session-one',
section,
});
} else if (message.type === 'parse-onepkg' && this.packageResult) {
this.respond({
type: 'package',
requestId: message.requestId,
sessionId: 'session-package',
notebook: this.packageResult,
});
} else if (message.type === 'get-page') {
const page = section.pages[0]!;
this.respond({
type: 'page',
requestId: message.requestId,
sessionId: message.sessionId,
page: {
...page,
id: message.pageId,
blocks: this.resourcePage
? [
{
kind: 'image',
id: 'image-1',
resourceId: 'resource-image',
filename: 'large.png',
isBackground: false,
layout: {},
},
]
: [
{
kind: 'text',
id: 'text-1',
sourceText: page.text,
text: page.text,
runs: [
{
start: 0,
end: page.text.length,
text: page.text,
style: {},
},
],
paragraphStyle: {},
layout: {},
},
],
diagnostics: [],
},
});
} else if (message.type === 'get-resource') {
if (message.intent === 'preview') {
this.respond({
type: 'failure',
requestId: message.requestId,
error: {
code: 'resource-not-available',
message:
'This image can be downloaded but is not approved for automatic preview.',
recoverable: true,
},
});
} else {
this.respond({
type: 'resource',
requestId: message.requestId,
sessionId: message.sessionId,
resource: {
id: message.resourceId,
kind: 'image',
filename: 'large.png',
extension: 'png',
mediaType: 'image/png',
browserRenderable: false,
size: 4,
bytes: Uint8Array.of(1, 2, 3, 4).buffer,
},
});
}
} else if (message.type === 'export-session') {
this.respond({
type: 'export-artifact',
requestId: message.requestId,
sessionId: message.sessionId,
artifact: {
filename: 'Project notes.zip',
mediaType: 'application/zip',
bytes: Uint8Array.of(0x50, 0x4b, 3, 4).buffer,
},
});
}
});
}
terminate(): void {
this.terminated = true;
}
private respond(response: WorkerResponse): void {
this.onmessage?.(new MessageEvent('message', { data: response }));
}
}
class FailingParserWorker implements WorkerPort {
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null;
onerror: ((event: ErrorEvent) => void) | null = null;
postMessage(message: WorkerRequest): void {
if (message.type !== 'parse-one' && message.type !== 'parse-onepkg') return;
queueMicrotask(() => {
this.onmessage?.(
new MessageEvent('message', {
data: {
type: 'failure',
requestId: message.requestId,
error: {
code: 'invalid-format',
message: 'The source header is not a recognized OneNote file.',
recoverable: false,
structure: 'OneStore header',
},
} satisfies WorkerResponse,
})
);
});
}
terminate(): void {}
}
describe('OneNoteApplication', () => {
it('recognizes only supported local filename extensions', () => {
expect(sourceKindFromFileName('Section.ONE')).toBe('one');
expect(sourceKindFromFileName('Notebook.OnePkg')).toBe('onepkg');
expect(sourceKindFromFileName('notes.zip')).toBeNull();
});
it('opens a section, navigates its page, shows diagnostics, and clears', async () => {
const user = userEvent.setup();
const worker = new SuccessfulParserWorker();
render(
<OneNoteApplication
createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
/>
);
await user.upload(
screen.getByLabelText('Choose OneNote files'),
new File([new Uint8Array([1, 2, 3])], 'Section.one')
);
expect(
await screen.findByRole('heading', { name: 'First meeting' })
).toBeInTheDocument();
expect(
within(screen.getByRole('article', { name: 'First meeting' })).getByText(
'This is the complete extracted page text.'
)
).toBeInTheDocument();
expect(screen.getByText('UNSUPPORTED_IMAGE')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Clear file' }));
expect(worker.terminated).toBe(true);
expect(
screen.getByRole('heading', { name: 'Open a OneNote file' })
).toBeInTheDocument();
});
it('downloads a worker-built export after explicit user action', async () => {
const user = userEvent.setup();
const worker = new SuccessfulParserWorker();
const createObjectUrl = vi
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:local-export');
const click = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => undefined);
render(
<OneNoteApplication
createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
/>
);
await user.upload(
screen.getByLabelText('Choose OneNote files'),
new File([new Uint8Array([1, 2, 3])], 'Section.one')
);
await screen.findByRole('heading', { name: 'First meeting' });
await user.click(screen.getByRole('button', { name: 'Create export' }));
expect(
await screen.findByText('The export download is ready.')
).toBeVisible();
expect(worker.lastMessage).toEqual({
type: 'export-session',
requestId: 'export-3',
sessionId: 'session-one',
format: 'static-zip',
});
expect(createObjectUrl).toHaveBeenCalledWith(
expect.objectContaining({ type: 'application/zip' })
);
expect(click).toHaveBeenCalledOnce();
});
it('requests a rejected preview separately from an explicit image download', async () => {
const user = userEvent.setup();
const worker = new SuccessfulParserWorker(undefined, true);
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:local-image');
const click = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => undefined);
render(
<OneNoteApplication
createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
/>
);
await user.upload(
screen.getByLabelText('Choose OneNote files'),
new File([new Uint8Array([1, 2, 3])], 'Section.one')
);
expect(
await screen.findByText(/not approved for automatic preview/i)
).toBeVisible();
expect(worker.messages).toContainEqual(
expect.objectContaining({
type: 'get-resource',
resourceId: 'resource-image',
intent: 'preview',
})
);
expect(
worker.messages.some(
(message) =>
message.type === 'get-resource' && message.intent === 'download'
)
).toBe(false);
await user.click(
screen.getByRole('button', { name: 'Download original image' })
);
expect(worker.messages).toContainEqual(
expect.objectContaining({
type: 'get-resource',
resourceId: 'resource-image',
intent: 'download',
})
);
expect(click).toHaveBeenCalledOnce();
});
it('opens a dropped OneNote package through the local worker', async () => {
const notebook: OneNotePackageDto = {
format: 'onepkg',
sourceName: 'Notebook.onepkg',
sourceSize: 1,
entries: [],
sections: [],
tree: { interpreted: false, nodes: [] },
diagnostics: [],
};
const worker = new SuccessfulParserWorker(notebook);
render(
<OneNoteApplication
createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
/>
);
const part3 = new File([new Uint8Array([3])], 'Notebook-3.cab');
const primary = new File([new Uint8Array([1])], 'Notebook.onepkg');
const part2 = new File([new Uint8Array([2])], 'Notebook-2.cab');
fireEvent.drop(screen.getByTestId('file-drop-zone'), {
dataTransfer: {
files: [part3, primary, part2],
},
});
expect(
await screen.findByRole('heading', { name: 'Package summary' })
).toBeInTheDocument();
expect(worker.lastMessage).toEqual(
expect.objectContaining({
type: 'parse-onepkg',
fileName: 'Notebook.onepkg',
cabinetParts: [
expect.objectContaining({ fileName: 'Notebook-3.cab' }),
expect.objectContaining({ fileName: 'Notebook-2.cab' }),
],
})
);
expect(worker.lastTransfer).toHaveLength(3);
});
it('hands a multi-file picker selection to the package worker', async () => {
const notebook: OneNotePackageDto = {
format: 'onepkg',
sourceName: 'Notebook.onepkg',
sourceSize: 2,
entries: [],
sections: [],
tree: { interpreted: false, nodes: [] },
diagnostics: [],
};
const worker = new SuccessfulParserWorker(notebook);
const user = userEvent.setup();
render(
<OneNoteApplication
createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
/>
);
const input = screen.getByLabelText('Choose OneNote files');
expect(input).toHaveAttribute('multiple');
expect(input).toHaveAttribute('accept', '.one,.onepkg,.cab');
await user.upload(input, [
new File([new Uint8Array([2])], 'Notebook-2.cab'),
new File([new Uint8Array([1])], 'Notebook.onepkg'),
]);
expect(
await screen.findByRole('heading', { name: 'Package summary' })
).toBeInTheDocument();
expect(worker.lastMessage).toEqual(
expect.objectContaining({
type: 'parse-onepkg',
fileName: 'Notebook.onepkg',
cabinetParts: [expect.objectContaining({ fileName: 'Notebook-2.cab' })],
})
);
});
it('keeps a failed packaged section visible beside a readable one', async () => {
const notebook: OneNotePackageDto = {
format: 'onepkg',
sourceName: 'Notebook.onepkg',
sourceSize: 4096,
entries: [
{
path: 'Notes/Readable.one',
normalizedPath: 'Notes/Readable.one',
kind: 'section',
uncompressedSize: 2048,
compressionMethod: 'LZX',
parseStatus: 'parsed',
},
{
path: 'Notes/Broken.one',
normalizedPath: 'Notes/Broken.one',
kind: 'section',
uncompressedSize: 1024,
compressionMethod: 'LZX',
parseStatus: 'failed',
},
],
sections: [
{
id: 'readable',
path: 'Notes/Readable.one',
displayName: 'Readable',
parseStatus: 'parsed',
section,
diagnostics: [],
},
{
id: 'broken',
path: 'Notes/Broken.one',
displayName: 'Broken',
parseStatus: 'failed',
diagnostics: [
{
severity: 'error',
code: 'SECTION_PARSE_FAILED',
message: 'This section is malformed.',
recoverable: true,
},
],
},
],
tree: {
interpreted: true,
tocPath: 'Open Notebook.onetoc2',
nodes: [
{
kind: 'section-group',
id: 'Notes',
displayName: 'Notes',
children: [
{
kind: 'section',
id: 'readable',
sectionId: 'readable',
displayName: 'Readable',
},
{
kind: 'section',
id: 'broken',
sectionId: 'broken',
displayName: 'Broken',
},
],
},
],
},
diagnostics: [
{
severity: 'warning',
code: 'TEST_PACKAGE_WARNING',
message: 'Example recoverable package warning.',
recoverable: true,
},
],
};
const user = userEvent.setup();
render(
<OneNoteApplication
createWorkerClient={() =>
new OneNoteWorkerClient(() => new SuccessfulParserWorker(notebook))
}
/>
);
await user.upload(
screen.getByLabelText('Choose OneNote files'),
new File([new Uint8Array([1])], 'Notebook.onepkg')
);
expect(
await screen.findByRole('heading', { name: 'Package summary' })
).toBeInTheDocument();
expect(screen.getByText('Notes')).toBeVisible();
expect(screen.getByText('OneNote notebook order')).toBeVisible();
expect(
screen.getByRole('button', { name: /Broken, failed/i })
).toBeVisible();
expect(screen.getByText('TEST_PACKAGE_WARNING')).toBeInTheDocument();
expect(
await within(
screen.getByRole('article', { name: 'First meeting' })
).findByText('This is the complete extracted page text.')
).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Broken, failed/i }));
expect(screen.getByText('SECTION_PARSE_FAILED')).toBeInTheDocument();
expect(
screen.getByText('This section did not produce a readable page list.')
).toBeInTheDocument();
});
it('shows a controlled parser failure', async () => {
const user = userEvent.setup();
render(
<OneNoteApplication
createWorkerClient={() =>
new OneNoteWorkerClient(() => new FailingParserWorker())
}
/>
);
await user.upload(
screen.getByLabelText('Choose OneNote files'),
new File(['not OneNote'], 'Broken.one')
);
expect(await screen.findByRole('alert')).toHaveTextContent(
'The source header is not a recognized OneNote file.'
);
expect(screen.getByRole('alert')).toHaveTextContent(
'invalid-format · OneStore header'
);
});
it('rejects unrelated extensions without starting a worker', async () => {
const user = userEvent.setup({ applyAccept: false });
render(<OneNoteApplication />);
await user.upload(
screen.getByLabelText('Choose OneNote files'),
new File(['not a notebook'], 'notes.zip')
);
expect(await screen.findByRole('alert')).toHaveTextContent(
'Choose one .one file, or one .onepkg file together with its .cab parts.'
);
});
it('enforces the source-size safety limit before reading', async () => {
const user = userEvent.setup();
render(<OneNoteApplication />);
const file = new File(['tiny'], 'large.one');
Object.defineProperty(file, 'size', {
configurable: true,
value: MAX_SOURCE_FILE_BYTES + 1,
});
await user.upload(screen.getByLabelText('Choose OneNote files'), file);
expect(await screen.findByRole('alert')).toHaveTextContent('512 MiB');
});
});