diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index 34a15996..a324683e 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -45,7 +45,8 @@ import { SelectionToUseField as SelectionToUseField_cdf7e044479f899a31f804427d56 import { FieldsToExport as FieldsToExport_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc' import { CollectionField as CollectionField_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc' import { ExportPreview as ExportPreview_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc' -import { ExportSaveButton as ExportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc' +import { ExportDownloadButton as ExportDownloadButton_e4e9ab26ca06aed2422b0260db830bc4 } from '@/components/payload/ExportDownloadButton' +import { ExportSaveButtonFixed as ExportSaveButtonFixed_fa0881cafe9c287f640a39f3d41a8f6d } from '@/components/payload/ExportSaveButton' import { ImportPreview as ImportPreview_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc' import { ImportSaveButton as ImportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc' import { MaskedApiKeyField as MaskedApiKeyField_6e265c33523b378578f5880a6fcfdc0f } from '@/globals/Settings/tabs/MaskedApiKeyField' @@ -107,7 +108,8 @@ export const importMap = { "@payloadcms/plugin-import-export/rsc#FieldsToExport": FieldsToExport_cdf7e044479f899a31f804427d568b36, "@payloadcms/plugin-import-export/rsc#CollectionField": CollectionField_cdf7e044479f899a31f804427d568b36, "@payloadcms/plugin-import-export/rsc#ExportPreview": ExportPreview_cdf7e044479f899a31f804427d568b36, - "@payloadcms/plugin-import-export/rsc#ExportSaveButton": ExportSaveButton_cdf7e044479f899a31f804427d568b36, + "@/components/payload/ExportDownloadButton#ExportDownloadButton": ExportDownloadButton_e4e9ab26ca06aed2422b0260db830bc4, + "@/components/payload/ExportSaveButton#ExportSaveButtonFixed": ExportSaveButtonFixed_fa0881cafe9c287f640a39f3d41a8f6d, "@payloadcms/plugin-import-export/rsc#ImportPreview": ImportPreview_cdf7e044479f899a31f804427d568b36, "@payloadcms/plugin-import-export/rsc#ImportSaveButton": ImportSaveButton_cdf7e044479f899a31f804427d568b36, "@/globals/Settings/tabs/MaskedApiKeyField#MaskedApiKeyField": MaskedApiKeyField_6e265c33523b378578f5880a6fcfdc0f, diff --git a/src/components/payload/ExportDownloadButton.tsx b/src/components/payload/ExportDownloadButton.tsx new file mode 100644 index 00000000..3d5508df --- /dev/null +++ b/src/components/payload/ExportDownloadButton.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Button, useDocumentInfo, useTranslation } from "@payloadcms/ui"; + +/** + * Renders an always-available "Download" control for a saved Export doc. + * + * The exports collection denies update access to everyone by design + * (`access.update: () => false` in @payloadcms/plugin-import-export's + * getExportCollection.js — exports are immutable once generated). Payload's + * Edit view only mounts the `edit.SaveButton` slot when the viewer has + * update permission (see @payloadcms/ui's DocumentControls and + * @payloadcms/next's renderDocumentSlots), so on a saved doc that slot + * never renders at all — not merely disabled. That's why the plugin's own + * Download button (and our ExportSaveButtonFixed fork of it) disappears + * the moment you navigate away and back. + * + * `beforeDocumentControls` is rendered unconditionally, regardless of save + * permission, so it's the right slot for a control that only needs read + * access to the file already attached to the doc. + */ +export const ExportDownloadButton = () => { + const { t } = useTranslation(); + const { id, savedDocumentData } = useDocumentInfo(); + const url = + typeof savedDocumentData?.url === "string" ? savedDocumentData.url : undefined; + const filename = + typeof savedDocumentData?.filename === "string" + ? savedDocumentData.filename + : undefined; + + if (!id || !url) { + return null; + } + + const handleClick = () => { + const a = document.createElement("a"); + a.href = url; + a.download = filename ?? ""; + a.rel = "noopener noreferrer"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }; + + return ( + + ); +}; diff --git a/src/components/payload/ExportSaveButton.tsx b/src/components/payload/ExportSaveButton.tsx new file mode 100644 index 00000000..ac5e4583 --- /dev/null +++ b/src/components/payload/ExportSaveButton.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { + Button, + SaveButton, + toast, + useConfig, + useField, + useForm, + useFormModified, + useTranslation, +} from "@payloadcms/ui"; +import { formatAdminURL } from "payload/shared"; +import React from "react"; + +/** + * Forks @payloadcms/plugin-import-export's ExportSaveButton to fix two + * issues with its Download button once an export has already been saved: + * + * 1. It's disabled via `disabled: !modified` — as soon as the doc is saved, + * the form is no longer "modified", so the button becomes permanently + * unclickable the next time the doc is opened. + * 2. Even when enabled, it always POSTs to `/exports/download` to + * regenerate a brand-new file from the current form fields rather than + * serving the file already attached to the doc. + * + * Here, whenever the doc already has a saved file, Download just opens that + * file directly. Regeneration is kept as a fallback for brand-new/edited + * exports that have no saved file yet. + */ +export const ExportSaveButtonFixed = () => { + const { t } = useTranslation(); + const { + config: { + routes: { api }, + }, + getEntityConfig, + } = useConfig(); + const { getData, setModified } = useForm(); + const modified = useFormModified(); + const { value: targetCollectionSlug } = useField({ + path: "collectionSlug", + }); + const targetCollectionConfig = getEntityConfig({ + collectionSlug: targetCollectionSlug as string, + }); + const targetPluginConfig = ( + targetCollectionConfig?.admin?.custom as + | { ["plugin-import-export"]?: { disableSave?: boolean; disableDownload?: boolean } } + | undefined + )?.["plugin-import-export"]; + const exportsCollectionConfig = getEntityConfig({ + collectionSlug: "exports", + }); + const exportsAdminCustom = exportsCollectionConfig?.admin?.custom as + | { disableSave?: boolean; disableDownload?: boolean } + | undefined; + const disableSave = + targetPluginConfig?.disableSave ?? exportsAdminCustom?.disableSave === true; + const disableDownload = + targetPluginConfig?.disableDownload ?? + exportsAdminCustom?.disableDownload === true; + const label = t("general:save"); + + const downloadSavedFile = (url: string, filename?: string) => { + const a = document.createElement("a"); + a.href = url; + a.download = filename ?? ""; + a.rel = "noopener noreferrer"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }; + + const regenerateAndDownload = async (data: Record) => { + let timeoutID: ReturnType | null = null; + let toastID: string | number | null = null; + try { + setModified(false); + timeoutID = setTimeout(() => { + toastID = toast.success("Your export is being processed..."); + }, 200); + const response = await fetch( + formatAdminURL({ apiRoute: api, path: "/exports/download" }), + { + body: JSON.stringify({ data }), + credentials: "include", + headers: { "Content-Type": "application/json" }, + method: "POST", + }, + ); + if (timeoutID) { + clearTimeout(timeoutID); + } + if (toastID) { + toast.dismiss(toastID); + } + if (!response.ok) { + let errorMsg = "Failed to download file"; + try { + const errorJson = await response.json(); + if (errorJson?.errors?.[0]?.message) { + errorMsg = errorJson.errors[0].message; + } + } catch { + // Ignore JSON parse errors, fallback to generic message + } + throw new Error(errorMsg); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + downloadSavedFile(url, `${data.name}-${data.collectionSlug}.${data.format}`); + URL.revokeObjectURL(url); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Error downloading file"); + } + }; + + const handleDownload = async () => { + const data = getData(); + + if (!modified && typeof data?.url === "string" && data.url) { + downloadSavedFile(data.url, typeof data.filename === "string" ? data.filename : undefined); + return; + } + + await regenerateAndDownload(data); + }; + + return ( + + {!disableSave && } + {!disableDownload && ( + + )} + + ); +}; diff --git a/src/lib/exportColumnLabels.ts b/src/lib/exportColumnLabels.ts new file mode 100644 index 00000000..68570f69 --- /dev/null +++ b/src/lib/exportColumnLabels.ts @@ -0,0 +1,49 @@ +import { getTranslation } from "@payloadcms/translations"; +import { CollectionSlug, PayloadRequest } from "payload"; +import { ExportBeforeHook } from "@payloadcms/plugin-import-export/types"; + +const titleCase = (key: string): string => + key + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/^./, (char) => char.toUpperCase()); + +const getFieldLabelMap = ( + req: PayloadRequest, + collectionSlug: CollectionSlug, +): Map => { + const collectionConfig = req.payload.collections[collectionSlug]?.config; + const map = new Map(); + + for (const field of collectionConfig?.flattenedFields ?? []) { + if (!("name" in field) || typeof field.name !== "string") { + continue; + } + + const label = "label" in field ? field.label : undefined; + map.set( + field.name, + label ? String(getTranslation(label, req.i18n)) : titleCase(field.name), + ); + } + + return map; +}; + +/** + * Renames export row keys from raw field names (e.g. "tenantName") to their + * configured admin labels (e.g. "Tenant Name") so downloaded CSV/JSON exports + * are human-readable rather than showing internal field names. + */ +export const createExportHeaderHook = + (collectionSlug: CollectionSlug): ExportBeforeHook => + ({ data, req }) => { + const labelMap = getFieldLabelMap(req, collectionSlug); + + return data.map((row) => { + const relabeledRow: Record = {}; + for (const [key, value] of Object.entries(row)) { + relabeledRow[labelMap.get(key) ?? titleCase(key)] = value; + } + return relabeledRow; + }); + }; diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 4ed11846..c91eb4a4 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -5,6 +5,7 @@ import { multiTenantPlugin } from "@payloadcms/plugin-multi-tenant"; import { importExportPlugin } from "@payloadcms/plugin-import-export"; import { Config } from "@/payload-types"; import { capitalizeFirstLetter, isProd } from "@/utils/utils"; +import { createExportHeaderHook } from "@/lib/exportColumnLabels"; import { s3Storage } from "@payloadcms/storage-s3"; import { seoPlugin } from "@payloadcms/plugin-seo"; import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; @@ -21,11 +22,19 @@ export const plugins: Plugin[] = [ collections: [ { slug: "promises", + export: { + hooks: { + before: createExportHeaderHook("promises"), + }, + }, }, { slug: "ai-extraction-export-rows", export: { format: "csv", + hooks: { + before: createExportHeaderHook("ai-extraction-export-rows"), + }, }, import: false, }, @@ -39,6 +48,26 @@ export const plugins: Plugin[] = [ }, admin: { ...collection.admin, + components: { + ...collection.admin?.components, + edit: { + ...collection.admin?.components?.edit, + // The plugin's default Download button is disabled once the doc + // is saved and, even when enabled, regenerates a fresh export + // instead of serving the saved file. This fork keeps it enabled + // and downloads the already-saved file when there is one. + SaveButton: "@/components/payload/ExportSaveButton#ExportSaveButtonFixed", + // The exports collection denies update access to everyone (it's + // immutable once generated), so Payload never mounts the + // edit.SaveButton slot above once a doc is saved — the "Fixed" + // button vanishes entirely on revisit, not just disabled. + // beforeDocumentControls renders unconditionally, so it's the + // only reliable place for an always-available redownload button. + beforeDocumentControls: [ + "@/components/payload/ExportDownloadButton#ExportDownloadButton", + ], + }, + }, group: { en: "Documents", fr: "Documents", @@ -67,6 +96,9 @@ export const plugins: Plugin[] = [ s3Storage({ collections: { media: true, + // Saved exports (Documents > Exports) must survive container + // restarts/redeploys, so they're durable enough to re-download later. + exports: true, }, bucket, config: { diff --git a/src/tasks/createPoliticalEntity.ts b/src/tasks/createPoliticalEntity.ts index f6675b93..8a8b3326 100644 --- a/src/tasks/createPoliticalEntity.ts +++ b/src/tasks/createPoliticalEntity.ts @@ -8,9 +8,9 @@ import { } from "@/lib/airtable"; import { TaskConfig } from "payload"; import { + computeMediaChecksum, downloadFile, removeDownloadedFile, - sha256File, } from "@/utils/files"; import { formatSlug } from "@/fields/slug/formatSlug"; import type { Media } from "@/payload-types"; @@ -254,7 +254,7 @@ export const CreatePoliticalEntity: TaskConfig = { ): Promise => { const filePath = await downloadFile(imageUrl, { fileName: alt }); try { - const checksum = await sha256File(filePath); + const checksum = await computeMediaChecksum(filePath); const existingMedia = await findMediaByChecksum(payload, checksum); if (existingMedia) { diff --git a/src/tasks/downloadDocuments.ts b/src/tasks/downloadDocuments.ts index 436a1bc9..188182a8 100644 --- a/src/tasks/downloadDocuments.ts +++ b/src/tasks/downloadDocuments.ts @@ -8,9 +8,9 @@ import { normalizeMediaSourceUrl, } from "@/lib/mediaUrl"; import { + computeMediaChecksum, downloadFile, removeDownloadedFile, - sha256File, } from "@/utils/files"; import { getTaskLogger, withTaskTracing, type TaskInput } from "./utils"; @@ -410,7 +410,7 @@ export const DownloadDocuments: TaskConfig<"downloadDocuments"> = { filePath = await downloadFile(fileUrl, { fileName: doc.title ?? undefined, }); - const checksum = await sha256File(filePath); + const checksum = await computeMediaChecksum(filePath); const existingMedia = await findMediaByChecksum( payload, checksum, diff --git a/src/utils/fileSignature.ts b/src/utils/fileSignature.ts index 44b92a55..4ea3b43d 100644 --- a/src/utils/fileSignature.ts +++ b/src/utils/fileSignature.ts @@ -28,6 +28,19 @@ const PNG = startsWith([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const GIF = (header: Buffer) => startsWith([0x47, 0x49, 0x46, 0x38, 0x37, 0x61])(header) || startsWith([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])(header); +// Deliberately checks only the major brand, not the compatible-brands list — +// this must match `file-type`'s own detection exactly (its ISO-BMFF branch +// switches on `brandMajor` alone), since that's what determines the +// `file.mimetype` Payload's `fileIsAnimatedType` check reencodes on. A file +// with a generic major brand (e.g. "mif1") and "avif" only in compatible +// brands is classified by file-type as image/heif, so Payload leaves it +// un-reencoded too — checking compatible brands here would make this more +// accurate than file-type and reintroduce the exact checksum mismatch this +// fixes. +const AVIF = (header: Buffer) => + startsWith([0x66, 0x74, 0x79, 0x70], 4)(header) && // "ftyp" box at offset 4 + (startsWith([0x61, 0x76, 0x69, 0x66], 8)(header) || // major brand "avif" + startsWith([0x61, 0x76, 0x69, 0x73], 8)(header)); // major brand "avis" const PDF = startsWith([0x25, 0x50, 0x44, 0x46]); // %PDF const ZIP = (header: Buffer) => startsWith([0x50, 0x4b, 0x03, 0x04])(header) || @@ -144,3 +157,36 @@ export const validateFileSignature = async ( await handle.close(); } }; + +export type PayloadReencodedMimeType = "image/gif" | "image/webp" | "image/avif"; + +/** + * Payload CMS unconditionally re-encodes uploads of these three mimetypes + * through sharp before persisting them (its `fileIsAnimatedType` check in + * `generateFileData.js`), even with no resize/format options configured. + * Detected from magic bytes, independent of any claimed Content-Type or + * file extension, so a pre-upload checksum can be computed against the same + * bytes Payload will actually store. + */ +export const detectPayloadReencodedFormat = async ( + filePath: string, +): Promise => { + const handle = await open(filePath, "r"); + try { + const buffer = Buffer.alloc(HEADER_LENGTH); + const { bytesRead } = await handle.read(buffer, 0, HEADER_LENGTH, 0); + const header = buffer.subarray(0, bytesRead); + if (isRiffWebp(header)) { + return "image/webp"; + } + if (GIF(header)) { + return "image/gif"; + } + if (AVIF(header)) { + return "image/avif"; + } + return null; + } finally { + await handle.close(); + } +}; diff --git a/src/utils/files.ts b/src/utils/files.ts index 5873b2db..c67abdc1 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -1,12 +1,16 @@ import { createHash, randomUUID } from "node:crypto"; import { createReadStream, createWriteStream } from "node:fs"; -import { mkdir, rm, unlink } from "node:fs/promises"; +import { mkdir, readFile, rm, unlink } from "node:fs/promises"; import { dirname, join, sep } from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { fileURLToPath } from "node:url"; +import sharp from "sharp"; -import { validateFileSignature } from "@/utils/fileSignature"; +import { + detectPayloadReencodedFormat, + validateFileSignature, +} from "@/utils/fileSignature"; import { assertResolvesToPublicAddresses, assertSafeRemoteUrl, @@ -339,3 +343,26 @@ export const sha256File = async (filePath: string): Promise => { export const sha256Buffer = (data: Buffer | Uint8Array): string => createHash("sha256").update(data).digest("hex"); + +/** + * Checksum to compare against Payload's stored `checksum` field for + * duplicate detection. For gif/webp/avif, Payload always re-encodes the + * upload through sharp before saving it (see `detectPayloadReencodedFormat`), + * so hashing the raw downloaded bytes would never match what actually gets + * persisted — replicate that re-encode step here before hashing so the two + * checksums are computed over the same bytes. + */ +export const computeMediaChecksum = async ( + filePath: string, +): Promise => { + const reencodedFormat = await detectPayloadReencodedFormat(filePath); + if (!reencodedFormat) { + return sha256File(filePath); + } + + const original = await readFile(filePath); + const reencoded = await sharp(original, { animated: true }) + .rotate() + .toBuffer(); + return sha256Buffer(reencoded); +}; diff --git a/tests/int/exportColumnLabels.int.spec.ts b/tests/int/exportColumnLabels.int.spec.ts new file mode 100644 index 00000000..3099dc87 --- /dev/null +++ b/tests/int/exportColumnLabels.int.spec.ts @@ -0,0 +1,87 @@ +import { createExportHeaderHook } from "@/lib/exportColumnLabels"; +import { describe, expect, it } from "vitest"; + +const fakeReq = (fields: Array<{ name: string; label?: unknown }>) => + ({ + payload: { + collections: { + "ai-extraction-export-rows": { + config: { + flattenedFields: fields, + }, + }, + }, + }, + i18n: { + language: "en", + fallbackLanguage: "en", + t: (key: string) => key, + }, + }) as never; + +describe("createExportHeaderHook", () => { + it("renames raw field keys to their configured admin labels", async () => { + const hook = createExportHeaderHook("ai-extraction-export-rows"); + const req = fakeReq([ + { name: "tenantName", label: "Tenant Name" }, + { name: "politicalEntityName", label: "Political Entity Name" }, + { name: "documentTitle", label: "Document Title" }, + { name: "category", label: "Category" }, + { name: "summary", label: "Summary" }, + { name: "statusLabel", label: "Status Label" }, + { name: "source", label: "Source" }, + { name: "checkMediaURL", label: "CheckMedia URL" }, + ]); + + const result = await hook({ + batchNumber: 1, + data: [ + { + tenantName: "Kenya", + politicalEntityName: "Jane Leader", + documentTitle: "Manifesto PDF", + category: "Health", + summary: "Build new clinics", + statusLabel: "In Progress", + source: "Clinic source quote", + checkMediaURL: "https://check.example.com/media/check-1", + }, + ], + format: "csv", + originalData: [], + req, + totalBatches: 1, + }); + + expect(result).toEqual([ + { + "Tenant Name": "Kenya", + "Political Entity Name": "Jane Leader", + "Document Title": "Manifesto PDF", + Category: "Health", + Summary: "Build new clinics", + "Status Label": "In Progress", + Source: "Clinic source quote", + "CheckMedia URL": "https://check.example.com/media/check-1", + }, + ]); + }); + + it("falls back to a title-cased key when a field has no configured label", async () => { + const hook = createExportHeaderHook("ai-extraction-export-rows"); + const req = fakeReq([{ name: "checkMediaId" }]); + + const result = await hook({ + batchNumber: 1, + data: [{ checkMediaId: "check-1", unknownRawKey: "value" }], + format: "csv", + originalData: [], + req, + totalBatches: 1, + }); + + expect(result).toEqual([ + { "Check Media Id": "check-1", "Unknown Raw Key": "value" }, + ]); + }); +}); diff --git a/tests/int/files.int.spec.ts b/tests/int/files.int.spec.ts index 5a0515e2..34cceada 100644 --- a/tests/int/files.int.spec.ts +++ b/tests/int/files.int.spec.ts @@ -15,12 +15,15 @@ vi.mock("node:dns/promises", () => { return { default: { lookup }, lookup }; }); +import sharp from "sharp"; import { + computeMediaChecksum, downloadFile, removeDownloadedFile, sha256Buffer, sha256File, } from "@/utils/files"; +import { detectPayloadReencodedFormat } from "@/utils/fileSignature"; const sha256 = (value: string) => createHash("sha256").update(value).digest("hex"); @@ -49,6 +52,80 @@ describe("sha256File", () => { }); }); +describe("computeMediaChecksum", () => { + const withTempFile = async ( + fileName: string, + data: Buffer, + run: (filePath: string) => Promise, + ): Promise => { + const dir = await mkdtemp(join(tmpdir(), "checksum-spec-")); + const filePath = join(dir, fileName); + await writeFile(filePath, data); + try { + return await run(filePath); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }; + + it("hashes non-reencoded formats (e.g. png) as raw bytes, same as sha256File", async () => { + const png = await sharp({ + create: { width: 4, height: 4, channels: 3, background: "red" }, + }) + .png() + .toBuffer(); + + await withTempFile("image.png", png, async (filePath) => { + expect(await detectPayloadReencodedFormat(filePath)).toBeNull(); + expect(await computeMediaChecksum(filePath)).toBe( + await sha256File(filePath), + ); + }); + }); + + it("hashes webp files against Payload's re-encoded bytes, not the raw download", async () => { + const webp = await sharp({ + create: { width: 4, height: 4, channels: 3, background: "blue" }, + }) + .webp() + .toBuffer(); + + await withTempFile("image.webp", webp, async (filePath) => { + expect(await detectPayloadReencodedFormat(filePath)).toBe( + "image/webp", + ); + + const expectedReencoded = await sharp(webp, { animated: true }) + .rotate() + .toBuffer(); + + const checksum = await computeMediaChecksum(filePath); + expect(checksum).toBe(sha256Buffer(expectedReencoded)); + // Sanity check that this actually differs from a naive raw-byte hash — + // otherwise the test wouldn't be exercising the fix at all. + expect(checksum).not.toBe(await sha256File(filePath)); + }); + }); + + it("produces the same checksum across repeated downloads of the same source image", async () => { + const webp = await sharp({ + create: { width: 6, height: 6, channels: 3, background: "green" }, + }) + .webp() + .toBuffer(); + + const checksums = await Promise.all( + [0, 1].map((run) => + withTempFile(`download-${run}.webp`, webp, (filePath) => + computeMediaChecksum(filePath), + ), + ), + ); + + expect(checksums[0]).toBe(checksums[1]); + }); +}); + describe("downloadFile", () => { const downloadedPaths: string[] = [];