575 lines
17 KiB
TypeScript
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');
|
|
});
|
|
});
|