Skip to content
10 changes: 6 additions & 4 deletions integration-tests/tests/pages/submission.page.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Page } from '@playwright/test';
import { gzipSync } from 'zlib';
import { ReviewPage } from './review.page';
import Papa from 'papaparse';
import { NavigationPage } from './navigation.page';
Expand Down Expand Up @@ -179,17 +180,18 @@ export class BulkSubmissionPage extends SubmissionPage {
* Content is provided as list(s) of strings, and will be formatted into a TSV file.
* @param headers The header row cells in the TSV file. The column headers need to be valid input field names.
* @param rows A list of rows. For each row, a value for each column must be given.
* @param compressFile Whether to compress the TSV (with GZIP) and upload it as `metadata.tsv.gz`.
*/
async uploadMetadataFile(headers: string[], rows: (string | number)[][]) {
async uploadMetadataFile(headers: string[], rows: (string | number)[][], compressFile = false) {
const tsvContent = Papa.unparse([headers, ...rows], {
delimiter: '\t',
newline: '\n',
});

await this.page.getByTestId('metadata_file').setInputFiles({
name: 'metadata.tsv',
mimeType: 'text/plain',
buffer: Buffer.from(tsvContent),
name: compressFile ? 'metadata.tsv.gz' : 'metadata.tsv',
mimeType: compressFile ? 'application/gzip' : 'text/plain',
buffer: compressFile ? gzipSync(Buffer.from(tsvContent)) : Buffer.from(tsvContent),
});
}

Expand Down
210 changes: 110 additions & 100 deletions integration-tests/tests/specs/features/file-sharing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ const FILES_DOUBLE: Record<string, string> = {
'file2.fastq': EBOLA_SUDAN_SMALL_FASTQ(2),
};

// For parameterised tests over compressed/uncompressed metadata
const METADATA_COMPRESSION_CASES = [
{ compressMetadata: false, description: '' },
{ compressMetadata: true, description: ' with compressed metadata file' },
];

Comment on lines +41 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regression itself is entirely client-side (the file factory in FormOrUploadWrapper), so it can be pinned much more cheaply than by re-running a 240 s end-to-end submission. FormOrUploadWrapper.spec.tsx already has a MockSaveWrapper harness that calls the file factory directly; a bulk-mode case that uploads a gzipSynced TSV with files.enabled and asserts the returned metadataFile.text() contains the headers would fail before this fix and run in milliseconds. Worth adding regardless of whether you keep the Playwright parameterisation.

// File cells can be formatted either as a list of file names,
// Or file names with file paths under a subfolder
const filesColumnCell = (fileNames: string[], subfolder?: string) =>
Expand Down Expand Up @@ -127,47 +133,49 @@ test('reject FASTQ raw_reads file with human host reads with a deacon validation
await reviewPage.expectNoValidSequencesToApprove();
});

test('bulk submit 2 seqs with 1 & 2 FASTQ files respectively', async ({
page,
groupId,
tmpDir,
}) => {
test.setTimeout(240_000);
void groupId;
const submissionPage = new BulkSubmissionPage(page);
await submissionPage.navigateToSubmissionPage(ORGANISM_NAME);
await submissionPage.uploadMetadataFile(
[...METADATA_HEADERS, RAW_READS_FILES_HEADER],
[
[
ID_1,
COUNTRY_1,
'2022-12-02',
SEQUENCING_INSTRUMENT,
filesColumnCell(Object.keys(FILES_SINGLE), ID_1),
],
[
ID_2,
COUNTRY_2,
'2022-12-13',
SEQUENCING_INSTRUMENT,
filesColumnCell(Object.keys(FILES_DOUBLE), ID_2),
],
],
);
await submissionPage.uploadSequencesFile({
[ID_1]: EBOLA_SUDAN_SHORT_SEQUENCE,
[ID_2]: EBOLA_SUDAN_SHORT_SEQUENCE,
});
await submissionPage.uploadExternalFiles(
RAW_READS,
{ [ID_1]: FILES_SINGLE, [ID_2]: FILES_DOUBLE },
tmpDir,
METADATA_COMPRESSION_CASES.forEach(({ compressMetadata, description }) => {
test(
'bulk submit 2 seqs with 1 & 2 FASTQ files respectively' + description,
async ({ page, groupId, tmpDir }) => {
test.setTimeout(240_000);
void groupId;
const submissionPage = new BulkSubmissionPage(page);
await submissionPage.navigateToSubmissionPage(ORGANISM_NAME);
await submissionPage.uploadMetadataFile(
[...METADATA_HEADERS, RAW_READS_FILES_HEADER],
[
[
ID_1,
COUNTRY_1,
'2022-12-02',
SEQUENCING_INSTRUMENT,
filesColumnCell(Object.keys(FILES_SINGLE), ID_1),
],
[
ID_2,
COUNTRY_2,
'2022-12-13',
SEQUENCING_INSTRUMENT,
filesColumnCell(Object.keys(FILES_DOUBLE), ID_2),
],
],
compressMetadata,
);
await submissionPage.uploadSequencesFile({
[ID_1]: EBOLA_SUDAN_SHORT_SEQUENCE,
[ID_2]: EBOLA_SUDAN_SHORT_SEQUENCE,
});
await submissionPage.uploadExternalFiles(
RAW_READS,
{ [ID_1]: FILES_SINGLE, [ID_2]: FILES_DOUBLE },
tmpDir,
);
const reviewPage = await submissionPage.submitAndWaitForProcessingDone(180_000);
const searchPage = await reviewPage.releaseAndGoToReleasedSequences();
await searchPage.checkFileContentInModal('cell', COUNTRY_1, FILES_SINGLE);
await searchPage.checkFileContentInModal('cell', COUNTRY_2, FILES_DOUBLE);
},
);
const reviewPage = await submissionPage.submitAndWaitForProcessingDone(180_000);
const searchPage = await reviewPage.releaseAndGoToReleasedSequences();
await searchPage.checkFileContentInModal('cell', COUNTRY_1, FILES_SINGLE);
await searchPage.checkFileContentInModal('cell', COUNTRY_2, FILES_DOUBLE);
});

test('bulk submit 1 seq: discarding and reading a FASTQ file', async ({
Expand Down Expand Up @@ -237,67 +245,69 @@ test('bulk submit 1 seq with a 35 MB FASTQ file', async ({ page, groupId, tmpDir
await searchPage.checkFileContentInModal('cell', COUNTRY_1, LARGE_FILE);
});

test('bulk submit blocks a submission with errors in file linkage or parsing', async ({
page,
groupId,
tmpDir,
}) => {
test.setTimeout(180_000);
void groupId;

const [file1Name, file2Name] = Object.keys(FILES_DOUBLE);
const file1 = { [file1Name]: FILES_DOUBLE[file1Name] };
const reusedFileId = '123e4567-e89b-12d3-a456-426614174000';

const linkageErrors = [
{
metadataFileEntries: 'a::b::c',
uploadedFiles: undefined,
error: 'Failed to parse file entry',
},
{
metadataFileEntries: filesColumnCell(Object.keys(FILES_DOUBLE), ID_1),
uploadedFiles: { [ID_1]: file1 },
error: `referenced in metadata but not uploaded: ${ID_1}/${file2Name}`,
},
{
metadataFileEntries: filesColumnCell(Object.keys(file1), ID_1),
uploadedFiles: { [ID_1]: FILES_DOUBLE },
error: `uploaded but not referenced in metadata: ${ID_1}/${file2Name}`,
METADATA_COMPRESSION_CASES.forEach(({ compressMetadata, description }) => {
test(
'bulk submit blocks a submission with errors in file linkage or parsing' + description,
async ({ page, groupId, tmpDir }) => {
Comment on lines +248 to +251

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test never reaches the code path the PR fixes: all four cases bail out in DataUploadForm.handleSubmit at the mapping/linkage error checks before applyFileMappings writes anything, and the mapping parse path already used metadataFile.text() (so it decompressed correctly before this fix too). The compressed variant here therefore passes both with and without the fix, while costing another ~3 min of CI on top of the extra run of the 240 s test above.

Consider parameterising only the success-path test at line 136 and leaving this one single-cased.

test.setTimeout(180_000);
void groupId;

const [file1Name, file2Name] = Object.keys(FILES_DOUBLE);
const file1 = { [file1Name]: FILES_DOUBLE[file1Name] };
const reusedFileId = '123e4567-e89b-12d3-a456-426614174000';

const linkageErrors = [
{
metadataFileEntries: 'a::b::c',
uploadedFiles: undefined,
error: 'Failed to parse file entry',
},
{
metadataFileEntries: filesColumnCell(Object.keys(FILES_DOUBLE), ID_1),
uploadedFiles: { [ID_1]: file1 },
error: `referenced in metadata but not uploaded: ${ID_1}/${file2Name}`,
},
{
metadataFileEntries: filesColumnCell(Object.keys(file1), ID_1),
uploadedFiles: { [ID_1]: FILES_DOUBLE },
error: `uploaded but not referenced in metadata: ${ID_1}/${file2Name}`,
},
{
// Uploaded without a submission ID subfolder, so the uploaded file's path matches
// the name of a metadata entry which already references an existing file
metadataFileEntries: `${file1Name}:${reusedFileId}`,
uploadedFiles: file1,
error: `uploaded but the metadata still references an existing file for them: ${file1Name}`,
},
];

const submissionPage = new BulkSubmissionPage(page);
for (const { metadataFileEntries, uploadedFiles, error } of linkageErrors) {
await submissionPage.navigateToSubmissionPage(ORGANISM_NAME);
await submissionPage.acceptTerms();
await submissionPage.uploadMetadataFile(
[...METADATA_HEADERS, RAW_READS_FILES_HEADER],
[[ID_1, COUNTRY_1, '2023-01-01', SEQUENCING_INSTRUMENT, metadataFileEntries]],
compressMetadata,
);
await submissionPage.uploadSequencesFile({
[ID_1]: EBOLA_SUDAN_SHORT_SEQUENCE,
});
if (uploadedFiles !== undefined)
await submissionPage.uploadExternalFiles(RAW_READS, uploadedFiles, tmpDir);

await submissionPage.clickSubmit();

// Multiple toasts can be shown at the same time
// For example, the parse error appears on metadata file load, as well as on handle submit
await expect(page.getByText(error).first()).toBeVisible();
// A blocked submission returns before the data use terms dialog is shown
await expect(
page.getByRole('button', { name: 'Continue under Open terms' }),
).toHaveCount(0);
}
},
{
// Uploaded without a submission ID subfolder, so the uploaded file's path matches
// the name of a metadata entry which already references an existing file
metadataFileEntries: `${file1Name}:${reusedFileId}`,
uploadedFiles: file1,
error: `uploaded but the metadata still references an existing file for them: ${file1Name}`,
},
];

const submissionPage = new BulkSubmissionPage(page);
for (const { metadataFileEntries, uploadedFiles, error } of linkageErrors) {
await submissionPage.navigateToSubmissionPage(ORGANISM_NAME);
await submissionPage.acceptTerms();
await submissionPage.uploadMetadataFile(
[...METADATA_HEADERS, RAW_READS_FILES_HEADER],
[[ID_1, COUNTRY_1, '2023-01-01', SEQUENCING_INSTRUMENT, metadataFileEntries]],
);
await submissionPage.uploadSequencesFile({
[ID_1]: EBOLA_SUDAN_SHORT_SEQUENCE,
});
if (uploadedFiles !== undefined)
await submissionPage.uploadExternalFiles(RAW_READS, uploadedFiles, tmpDir);

await submissionPage.clickSubmit();

// Multiple toasts can be shown at the same time
// For example, the parse error appears on metadata file load, as well as on handle submit
await expect(page.getByText(error).first()).toBeVisible();
// A blocked submission returns before the data use terms dialog is shown
await expect(page.getByRole('button', { name: 'Continue under Open terms' })).toHaveCount(
0,
);
}
);
});

test('bulk submit blocks a submission with an invalid file name', async ({
Expand Down
26 changes: 21 additions & 5 deletions website/src/components/Submission/FormOrUploadWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,30 @@ export const FormOrUploadWrapper: FC<FormOrUploadWrapperProps> = ({
};
}
case 'bulk': {
let mFile = metadataFile?.inner();
if (metadataFile !== undefined && columnMapping !== null) {
mFile = await columnMapping.applyTo(metadataFile);
}
if (mFile === undefined) {
if (metadataFile === undefined) {
return { type: 'error', errorMessage: 'Please specify a metadata file.' };
}

let mFile: File;
try {
if (columnMapping !== null) {
mFile = await columnMapping.applyTo(metadataFile);
} else if (extraFilesEnabled) {
// Ensure the metadata file is decompressed so that any file mappings
// can be applied correctly
mFile = new File([await metadataFile.text()], 'metadata.tsv');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that the same file is decompressed twice per submission: the effect above (line 90-92) already calls metadataFile.text() to build the submission file mapping, and this runs it again at submit time. gzip/zstd decompression here is synchronous (fflate.decompressSync / fzstd.decompress in fileProcessing.ts), so for a large metadata file that's two main-thread stalls. Caching the decompressed text next to the mapping state (or threading the ProcessedFile through to DataUploadForm instead of materialising a File here, as your PR description suggests) would fix this along with the layering issue.

} else {
mFile = metadataFile.inner();
}
Comment on lines +149 to +155

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch fires for every bulk upload when files are enabled, not just compressed ones, so it has two side effects beyond the bug being fixed:

  • An already-plain .tsv (or an ExcelFile, whose inner() is already a converted TSV) gets fully buffered into a JS string and copied into a new File for no reason.
  • If the user uploaded a compressed metadata file but no file mappings end up resolved, applyFileMappings returns the file unchanged (fileMapping.ts:474), so we now upload the metadata decompressed to the backend where it used to go over the wire gzipped.

CompressedFile is the only ProcessedFile whose inner() differs from text(), so gating on it keeps the fix and avoids both:

Suggested change
} else if (extraFilesEnabled) {
// Ensure the metadata file is decompressed so that any file mappings
// can be applied correctly
mFile = new File([await metadataFile.text()], 'metadata.tsv');
} else {
mFile = metadataFile.inner();
}
} else if (extraFilesEnabled && metadataFile instanceof CompressedFile) {
// Ensure the metadata file is decompressed so that any file mappings
// can be applied correctly
mFile = new File([await metadataFile.text()], 'metadata.tsv');
} else {
mFile = metadataFile.inner();
}

(needs CompressedFile added to the import from ./FileUpload/fileProcessing).

} catch (error) {
return {
type: 'error',
errorMessage: `Could not read ${metadataFile.handle().name}: ${
error instanceof Error ? error.message : String(error)
}`,
};
}

const sFile = sequenceFile?.inner();
if (enableConsensusSequences && sFile === undefined) {
return { type: 'error', errorMessage: 'Please specify a sequences file.' };
Expand Down
Loading