diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index e2b5aa9186..ff75830066 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -25,7 +25,8 @@ jobs: - name: Check browser module syntax run: | node --check demo/web/app.mjs + node --check demo/web/batch.mjs node --check demo/web/output-directory.mjs node --check demo/web/worker.mjs - - name: Test folder output recovery - run: node --test demo/web/tests/output-directory.test.mjs + - name: Test browser converter helpers + run: node --test demo/web/tests/batch.test.mjs demo/web/tests/output-directory.test.mjs diff --git a/demo/web/archive.mjs b/demo/web/archive.mjs index a479f53bdd..fdcb24d89a 100644 --- a/demo/web/archive.mjs +++ b/demo/web/archive.mjs @@ -1,8 +1,9 @@ import { BlobReader, Uint8ArrayReader, ZipReader, ZipWriter, configure } from "https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.7.57/lib/zip-no-worker.js/+esm"; +import { batchMemoryEstimate, batchOutputDirectory, MEMORY_LIMIT, memoryEstimate } from "./batch.mjs"; configure({ useWebWorkers: false, useCompressionStream: false, chunkSize: 65536 }); -export const MEMORY_LIMIT = 256 * 1024 * 1024; +export { batchMemoryEstimate, batchOutputDirectory, memoryEstimate, MEMORY_LIMIT }; export const CONVERSION_LIMIT = 256 * 1024 * 1024; export const ICON_LIMIT = 16 * 1024 * 1024; const MAX_ENTRIES = 100000; @@ -204,27 +205,6 @@ export class Archive { async close() { await this.reader.close(); } } -export function memoryEstimate(archiveBytes, selectedBytes, libraryBytes, entries) { - return safeSize(Math.ceil(archiveBytes * 1.02) + selectedBytes * 4 + libraryBytes + entries * 1024 + 8 * 1024 * 1024); -} - -export function batchMemoryEstimate(summaries, target, libraryBytes) { - if (target !== "linux" && target !== "windows") throw new Error(`Unsupported conversion target: ${target}`); - return summaries.reduce((total, summary) => safeSize(total + memoryEstimate( - summary.totalBytes, - target === "windows" ? summary.windowsSelectedBytes : summary.selectedBytes, - libraryBytes, - summary.files - )), 0); -} - -export function batchOutputDirectory(name, index) { - if (!Number.isSafeInteger(index) || index < 0) throw new Error("Invalid app index"); - const stem = String(name).replace(/\.zip$/iu, "").normalize("NFKC") - .replace(/[^A-Za-z0-9._-]+/gu, "-").replace(/^[.-]+/u, "").slice(0, 80).replace(/[. -]+$/u, ""); - return `${String(index + 1).padStart(2, "0")}-${stem || "app"}`; -} - export function createPackageWriter(destination) { return new ZipWriter(destination, { level: 9, zip64: true, bufferedWrite: false, useWebWorkers: false, useCompressionStream: false, preventClose: true }); } diff --git a/demo/web/batch.mjs b/demo/web/batch.mjs new file mode 100644 index 0000000000..5058dbea4f --- /dev/null +++ b/demo/web/batch.mjs @@ -0,0 +1,27 @@ +export const MEMORY_LIMIT = 256 * 1024 * 1024; + +function safeSize(value) { + if (!Number.isSafeInteger(value) || value < 0) throw new Error("ZIP contains an unsupported file size"); + return value; +} + +export function memoryEstimate(archiveBytes, selectedBytes, libraryBytes, entries) { + return safeSize(Math.ceil(archiveBytes * 1.02) + selectedBytes * 4 + libraryBytes + entries * 1024 + 8 * 1024 * 1024); +} + +export function batchMemoryEstimate(summaries, target, libraryBytes) { + if (target !== "linux" && target !== "windows") throw new Error(`Unsupported conversion target: ${target}`); + return summaries.reduce((total, summary) => safeSize(total + memoryEstimate( + summary.totalBytes, + target === "windows" ? summary.windowsSelectedBytes : summary.selectedBytes, + libraryBytes, + summary.files + )), 0); +} + +export function batchOutputDirectory(name, index) { + if (!Number.isSafeInteger(index) || index < 0) throw new Error("Invalid app index"); + const stem = String(name).replace(/\.zip$/iu, "").normalize("NFKC") + .replace(/[^A-Za-z0-9._-]+/gu, "-").replace(/^[.-]+/u, "").slice(0, 80).replace(/[. -]+$/u, ""); + return `${String(index + 1).padStart(2, "0")}-${stem || "app"}`; +} diff --git a/demo/web/tests/archive.test.mjs b/demo/web/tests/archive.test.mjs index 0bc401d74b..d0c2ca7c77 100644 --- a/demo/web/tests/archive.test.mjs +++ b/demo/web/tests/archive.test.mjs @@ -7,7 +7,7 @@ import { register } from "node:module"; register("./cdn-loader.mjs", import.meta.url, { data: { url: process.env.ANYPS5_TEST_ZIP_URL, path: process.env.ANYPS5_TEST_ZIP_PATH } }); const site = name => pathToFileURL(path.join(process.argv[2], name)).href; -const { Archive, batchMemoryEstimate, batchOutputDirectory, memoryEstimate, MEMORY_LIMIT, ICON_LIMIT, packageArchive } = await import(site("archive.mjs")); +const { Archive, memoryEstimate, MEMORY_LIMIT, ICON_LIMIT, packageArchive } = await import(site("archive.mjs")); const { BlobReader, BlobWriter, Uint8ArrayReader, Uint8ArrayWriter, ZipReader, ZipWriter } = await import(process.env.ANYPS5_TEST_ZIP_URL); await import("./archive-reserved.test.mjs"); const { default: createRelinker } = await import(site("native/relinker_wasm.mjs")); @@ -157,16 +157,6 @@ assert.equal(selfModuleSummary.warnings.length, 2); assert.match(selfModuleSummary.warnings.join(" "), /SELF container/u); await selfModule.close(); assert(memoryEstimate(200 * 1024 ** 3, 1024, 1024, 100) > MEMORY_LIMIT); -assert(batchMemoryEstimate([ - { totalBytes: 1024, selectedBytes: 128, windowsSelectedBytes: 256, files: 2 }, - { totalBytes: 2048, selectedBytes: 256, windowsSelectedBytes: 512, files: 3 } -], "linux", 1024) > memoryEstimate(2048, 256, 1024, 3)); -assert.equal(batchOutputDirectory("First App.zip", 0), "01-First-App"); -assert.equal(batchOutputDirectory("First App.zip", 1), "02-First-App"); -assert.equal(batchOutputDirectory("CON.zip", 0), "01-CON"); -assert.equal(batchOutputDirectory("...zip", 0), "01-app"); -assert.throws(() => batchOutputDirectory("app.zip", -1), /Invalid app index/u); -assert.throws(() => batchMemoryEstimate([], "macos", 0), /Unsupported conversion target/u); const invalid = native.Convert({ target: "unsupported" }, () => {}); assert.equal(invalid.ok, false); console.log("Archive validation, missing-folder warnings and large-download budget verified"); diff --git a/demo/web/tests/batch.test.mjs b/demo/web/tests/batch.test.mjs new file mode 100644 index 0000000000..b19827d72b --- /dev/null +++ b/demo/web/tests/batch.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { batchMemoryEstimate, batchOutputDirectory, memoryEstimate, MEMORY_LIMIT } from "../batch.mjs"; + +test("estimates all ZIP outputs against one shared memory limit", () => { + const summaries = [ + { totalBytes: 1024, selectedBytes: 128, windowsSelectedBytes: 256, files: 2 }, + { totalBytes: 2048, selectedBytes: 256, windowsSelectedBytes: 512, files: 3 } + ]; + + assert.equal(batchMemoryEstimate(summaries, "linux", 1024), summaries.reduce( + (total, summary) => total + memoryEstimate(summary.totalBytes, summary.selectedBytes, 1024, summary.files), + 0 + )); + assert.equal(batchMemoryEstimate(summaries, "windows", 1024), summaries.reduce( + (total, summary) => total + memoryEstimate(summary.totalBytes, summary.windowsSelectedBytes, 1024, summary.files), + 0 + )); + assert.throws(() => batchMemoryEstimate([], "macos", 0), /Unsupported conversion target/u); + assert.throws(() => batchMemoryEstimate([{ totalBytes: Number.MAX_SAFE_INTEGER, selectedBytes: 0, windowsSelectedBytes: 0, files: 0 }], "linux", 0), /unsupported file size/u); + assert.equal(MEMORY_LIMIT, 256 * 1024 * 1024); +}); + +test("creates safe and distinct ZIP folder names for duplicate app filenames", () => { + assert.equal(batchOutputDirectory("First App.zip", 0), "01-First-App"); + assert.equal(batchOutputDirectory("First App.zip", 1), "02-First-App"); + assert.equal(batchOutputDirectory("CON.zip", 0), "01-CON"); + assert.equal(batchOutputDirectory("...zip", 0), "01-app"); + assert.throws(() => batchOutputDirectory("app.zip", -1), /Invalid app index/u); +}); diff --git a/demo/web/worker.mjs b/demo/web/worker.mjs index 59232fffa1..735533fafe 100644 --- a/demo/web/worker.mjs +++ b/demo/web/worker.mjs @@ -1,4 +1,5 @@ -import { Archive, batchMemoryEstimate, batchOutputDirectory, CONVERSION_LIMIT, MEMORY_LIMIT, createPackageWriter, packageArchive } from "./archive.mjs"; +import { batchMemoryEstimate, batchOutputDirectory, MEMORY_LIMIT } from "./batch.mjs"; +import { Archive, CONVERSION_LIMIT, createPackageWriter, packageArchive } from "./archive.mjs"; let archive; let inputs = [];