Commit 05da7ae5 authored by Robert Knight's avatar Robert Knight

Extract export file parsing out of ImportAnnotations component

Extract the reading and parsing of Hypothesis JSON files out of the
ImportAnnotations component and into a separate module. This allows for it to be
more directly tested, and also may help to fix test flakiness in
ImportAnnotations by replacing the file "reading" with a fake that takes a more
deterministic amount of time (one tick).

May fix https://github.com/hypothesis/client/issues/5746.
parent 37f7683b
import { Button, CardActions, Select } from '@hypothesis/frontend-shared'; import { Button, CardActions, Select } from '@hypothesis/frontend-shared';
import { useCallback, useEffect, useId, useMemo, useState } from 'preact/hooks'; import { useCallback, useEffect, useId, useMemo, useState } from 'preact/hooks';
import { isObject } from '../../../shared/is-object';
import { readJSONFile } from '../../../shared/read-json-file';
import type { APIAnnotationData } from '../../../types/api'; import type { APIAnnotationData } from '../../../types/api';
import { annotationDisplayName } from '../../helpers/annotation-user'; import { annotationDisplayName } from '../../helpers/annotation-user';
import { readExportFile } from '../../helpers/import';
import { withServices } from '../../service-context'; import { withServices } from '../../service-context';
import type { ExportContent } from '../../services/annotations-exporter';
import type { ImportAnnotationsService } from '../../services/import-annotations'; import type { ImportAnnotationsService } from '../../services/import-annotations';
import { useSidebarStore } from '../../store'; import { useSidebarStore } from '../../store';
import FileInput from './FileInput'; import FileInput from './FileInput';
import LoadingSpinner from './LoadingSpinner'; import LoadingSpinner from './LoadingSpinner';
/**
* Parse a file generated by the annotation exporter and return the extracted
* annotations.
*/
async function parseExportContent(file: File): Promise<APIAnnotationData[]> {
let json;
try {
json = await readJSONFile(file);
} catch (err) {
throw new Error('Not a valid JSON file');
}
// Perform some very rudimentary validation of the file content, just enough
// to catch issues where the user picked the wrong kind of JSON file.
if (!isObject(json) || !Array.isArray((json as any).annotations)) {
throw new Error('Not a valid Hypothesis JSON file');
}
return (json as ExportContent).annotations;
}
type UserAnnotationCount = { type UserAnnotationCount = {
userid: string; userid: string;
displayName: string; displayName: string;
...@@ -112,7 +89,7 @@ function ImportAnnotations({ ...@@ -112,7 +89,7 @@ function ImportAnnotations({
setAnnotations(null); setAnnotations(null);
setError(null); setError(null);
setSelectedUser(null); setSelectedUser(null);
parseExportContent(file) readExportFile(file)
.then(annotations => { .then(annotations => {
setAnnotations(annotations); setAnnotations(annotations);
......
...@@ -6,9 +6,12 @@ import ImportAnnotations, { $imports } from '../ImportAnnotations'; ...@@ -6,9 +6,12 @@ import ImportAnnotations, { $imports } from '../ImportAnnotations';
describe('ImportAnnotations', () => { describe('ImportAnnotations', () => {
let fakeImportAnnotationsService; let fakeImportAnnotationsService;
let fakeReadExportFile;
let fakeStore; let fakeStore;
beforeEach(() => { beforeEach(() => {
fakeReadExportFile = sinon.stub().rejects(new Error('Failed to read file'));
fakeImportAnnotationsService = { fakeImportAnnotationsService = {
import: sinon.stub().resolves([]), import: sinon.stub().resolves([]),
}; };
...@@ -21,6 +24,7 @@ describe('ImportAnnotations', () => { ...@@ -21,6 +24,7 @@ describe('ImportAnnotations', () => {
}; };
$imports.$mock({ $imports.$mock({
'../../helpers/import': { readExportFile: fakeReadExportFile },
'../../store': { useSidebarStore: () => fakeStore }, '../../store': { useSidebarStore: () => fakeStore },
}); });
}); });
...@@ -49,16 +53,18 @@ describe('ImportAnnotations', () => { ...@@ -49,16 +53,18 @@ describe('ImportAnnotations', () => {
return wrapper.find('button[data-testid="import-button"]'); return wrapper.find('button[data-testid="import-button"]');
} }
function selectFile(wrapper, data) { function selectFile(wrapper, readResult) {
const fileInput = wrapper.find('input[type="file"]'); const fileInput = wrapper.find('input[type="file"]');
const fileContent = typeof data === 'string' ? data : JSON.stringify(data); const file = new File(['dummy content'], 'export.json');
const file = new File([fileContent], 'export.json'); const transfer = new DataTransfer();
transfer.items.add(file);
// `HTMLInputElement.files` can be assigned, but is a `FileList`, which fileInput.getDOMNode().files = transfer.files;
// can't be constructed, so we just stub the property instead.
Object.defineProperty(fileInput.getDOMNode(), 'files', { if (readResult instanceof Error) {
get: () => [file], fakeReadExportFile.withArgs(file).rejects(readResult);
}); } else {
fakeReadExportFile.withArgs(file).resolves(readResult);
}
fileInput.simulate('change'); fileInput.simulate('change');
} }
...@@ -71,8 +77,7 @@ describe('ImportAnnotations', () => { ...@@ -71,8 +77,7 @@ describe('ImportAnnotations', () => {
it('displays user list when a valid file is selected', async () => { it('displays user list when a valid file is selected', async () => {
const wrapper = createImportAnnotations(); const wrapper = createImportAnnotations();
selectFile(wrapper, { selectFile(wrapper, [
annotations: [
{ {
user: 'acct:john@example.com', user: 'acct:john@example.com',
user_info: { user_info: {
...@@ -87,8 +92,7 @@ describe('ImportAnnotations', () => { ...@@ -87,8 +92,7 @@ describe('ImportAnnotations', () => {
}, },
text: 'Test annotation', text: 'Test annotation',
}, },
], ]);
});
const userList = await waitForElement(wrapper, 'Select'); const userList = await waitForElement(wrapper, 'Select');
const users = userList.find('option'); const users = userList.find('option');
...@@ -101,22 +105,11 @@ describe('ImportAnnotations', () => { ...@@ -101,22 +105,11 @@ describe('ImportAnnotations', () => {
assert.equal(users.at(2).text(), 'John Smith (1)'); assert.equal(users.at(2).text(), 'John Smith (1)');
}); });
// TODO - Check handling of errors when reading file fails
[
{
content: 'foobar',
reason: 'Not a valid JSON file',
},
{
content: {},
reason: 'Not a valid Hypothesis JSON file',
},
].forEach(({ content, reason }) => {
it('displays error if file is invalid', async () => { it('displays error if file is invalid', async () => {
const wrapper = createImportAnnotations(); const wrapper = createImportAnnotations();
const reason = 'Not a valid Hypothesis JSON file';
selectFile(wrapper, content); selectFile(wrapper, new Error(reason));
const error = await waitForElement(wrapper, '[data-testid="error-info"]'); const error = await waitForElement(wrapper, '[data-testid="error-info"]');
assert.equal( assert.equal(
...@@ -124,7 +117,6 @@ describe('ImportAnnotations', () => { ...@@ -124,7 +117,6 @@ describe('ImportAnnotations', () => {
`Unable to find annotations to import: ${reason}`, `Unable to find annotations to import: ${reason}`,
); );
}); });
});
it('selects user matching logged in user if found', async () => { it('selects user matching logged in user if found', async () => {
const wrapper = createImportAnnotations(); const wrapper = createImportAnnotations();
...@@ -137,7 +129,7 @@ describe('ImportAnnotations', () => { ...@@ -137,7 +129,7 @@ describe('ImportAnnotations', () => {
text: 'Test annotation', text: 'Test annotation',
}, },
]; ];
selectFile(wrapper, { annotations }); selectFile(wrapper, annotations);
const userList = await waitForElement(wrapper, 'Select'); const userList = await waitForElement(wrapper, 'Select');
assert.equal(userList.getDOMNode().value, 'acct:john@example.com'); assert.equal(userList.getDOMNode().value, 'acct:john@example.com');
...@@ -155,7 +147,7 @@ describe('ImportAnnotations', () => { ...@@ -155,7 +147,7 @@ describe('ImportAnnotations', () => {
text: 'Test annotation', text: 'Test annotation',
}, },
]; ];
selectFile(wrapper, { annotations }); selectFile(wrapper, annotations);
const userList = await waitForElement(wrapper, 'Select'); const userList = await waitForElement(wrapper, 'Select');
assert.equal(userList.getDOMNode().value, ''); assert.equal(userList.getDOMNode().value, '');
...@@ -180,7 +172,7 @@ describe('ImportAnnotations', () => { ...@@ -180,7 +172,7 @@ describe('ImportAnnotations', () => {
}, },
]; ];
selectFile(wrapper, { annotations }); selectFile(wrapper, annotations);
const userList = await waitForElement(wrapper, 'select'); const userList = await waitForElement(wrapper, 'select');
userList.getDOMNode().value = 'acct:brian@example.com'; userList.getDOMNode().value = 'acct:brian@example.com';
......
import { isObject } from '../../shared/is-object';
import { readJSONFile } from '../../shared/read-json-file';
import type { APIAnnotationData } from '../../types/api';
import type { ExportContent } from '../services/annotations-exporter';
/**
* Parse a file generated by the annotation exporter and return the extracted
* annotations.
*/
export async function readExportFile(file: File): Promise<APIAnnotationData[]> {
let json;
try {
json = await readJSONFile(file);
} catch (err) {
throw new Error('Not a valid JSON file');
}
// Perform some very rudimentary validation of the file content, just enough
// to catch issues where the user picked the wrong kind of JSON file.
if (!isObject(json) || !Array.isArray((json as any).annotations)) {
throw new Error('Not a valid Hypothesis JSON file');
}
return (json as ExportContent).annotations;
}
import { readExportFile } from '../import';
function createFile(content, name = 'example.json') {
if (typeof content === 'object') {
content = JSON.stringify(content);
}
return new File([content], name);
}
describe('readExportFile', () => {
it('throws error if file is not JSON', async () => {
let error;
try {
await readExportFile(createFile('foo bar'));
} catch (err) {
error = err;
}
assert.instanceOf(error, Error);
assert.equal(error.message, 'Not a valid JSON file');
});
[
// Top-level is not an object
[],
123,
// Missing `annotations` field
{},
// Invalid `annotations` field
{ annotations: 'not an array' },
].forEach(content => {
it('throws error if validation fails', async () => {
let error;
try {
await readExportFile(createFile(content));
} catch (err) {
error = err;
}
assert.instanceOf(error, Error);
assert.equal(error.message, 'Not a valid Hypothesis JSON file');
});
});
it('returns annotations from the file', async () => {
const annotations = [{ id: 'abc' }, { id: 'def' }];
const file = createFile({ annotations });
const parsedAnnotations = await readExportFile(file);
assert.deepEqual(parsedAnnotations, annotations);
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment