From 346e1acc9737ac225ac38ff156d8129e60e5e02f Mon Sep 17 00:00:00 2001 From: Arvids Godjuks Date: Wed, 14 Oct 2026 22:50:55 +0300 Subject: [PATCH] Fix Factorio mod release uploads with native fetch (#1080) Fix Factorio mod release uploads with native fetch --- publish.js | 18 ++++----- test/publish.js | 103 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 test/publish.js diff --git a/publish.js b/publish.js index 90aaabaf..2aa0fa7f 100644 --- a/publish.js +++ b/publish.js @@ -2,8 +2,9 @@ * Publish any mods that need to be published */ -import FormData from "form-data"; +import { openAsBlob } from "node:fs"; import fs from "node:fs/promises"; +import path from "node:path"; import yargs from "yargs"; let DRY = false; @@ -34,7 +35,7 @@ async function requestModReleases(mod) { /** * Upload a mod to the mod portal */ -async function uploadModRelease(file, name) { +export async function uploadModRelease(file, name) { // eslint-disable-next-line no-console console.log(`Uploading new release ${file}`); if (DRY) { @@ -61,7 +62,7 @@ async function uploadModRelease(file, name) { // Upload the new version of the mod const uploadUrl = (await initResponse.json()).upload_url; const uploadFormData = new FormData(); - uploadFormData.append("file", (await fs.open(file)).createReadStream(file)); + uploadFormData.append("file", await openAsBlob(file), path.basename(file)); const uploadResponse = await fetch(uploadUrl, { method: "POST", @@ -77,25 +78,24 @@ const regexModZip = /(\w+)_\d+.\d+.\d+\.zip/; /** * Publishes all mods that require publishing */ -async function publishMods(dir) { +export async function publishMods(dir) { const files = await fs.readdir(dir); const mods = files + .sort((a, b) => a.localeCompare(b, "en", { numeric: true })) .map(file => regexModZip.exec(file)) .filter(file => Boolean(file)); - const uploading = []; for (const mod of mods) { const [file, name] = mod; const releases = await requestModReleases(name); if (!releases.some(release => release.file_name === file)) { - uploading.push(uploadModRelease(file, name)); + // Upload in version order so the newest variant is published last. + await uploadModRelease(path.join(dir, file), name); } else { // eslint-disable-next-line no-console console.log(`Skipped ${file}`); } } - - await Promise.all(uploading); } /** @@ -121,7 +121,7 @@ async function main() { .demandOption("mods-dir", "A path to the mods dir must be specified"); }, async argv => { DRY = argv.dry; - publishMods(argv.modsDir); + await publishMods(argv.modsDir); }) .strict() .parse(); diff --git a/test/publish.js b/test/publish.js new file mode 100644 index 00000000..5066af4a --- /dev/null +++ b/test/publish.js @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { publishMods, uploadModRelease } from "../publish.js"; + +const execFileAsync = promisify(execFile); + + +describe("mod publishing", function() { + let originalFetch; + let directory; + + beforeEach(async function() { + originalFetch = global.fetch; + await fs.mkdir("temp", { recursive: true }); + directory = await fs.mkdtemp("temp/publish-test-"); + }); + + afterEach(async function() { + global.fetch = originalFetch; + await fs.rm(directory, { recursive: true, force: true }); + }); + + it("sends multipart mod and file fields accepted by native fetch", async function() { + const name = "multipart_test"; + const filename = `${name}_1.0.0.zip`; + const contents = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0xff]); + const file = path.join(directory, filename); + await fs.writeFile(file, contents); + let calls = 0; + global.fetch = async (url, options) => { + calls += 1; + const request = new Request(url, options); + assert.match(request.headers.get("content-type"), /^multipart\/form-data; boundary=/); + const form = await request.formData(); + if (calls === 1) { + assert.equal(url, "https://mods.factorio.com/api/v2/mods/releases/init_upload"); + assert.equal(form.get("mod"), name); + return Response.json({ upload_url: "https://upload.example.test/release" }); + } + assert.equal(url, "https://upload.example.test/release"); + const uploaded = form.get("file"); + assert.equal(uploaded.name, filename); + assert.deepEqual(Buffer.from(await uploaded.arrayBuffer()), contents); + return Response.json({ success: true }); + }; + await uploadModRelease(file, name); + assert.equal(calls, 2); + }); + + it("reads archives from the supplied directory and uploads unpublished versions in order", async function() { + const name = "ordered_publish_test"; + for (const version of ["1.0.9", "1.0.10", "1.0.2"]) { + await fs.writeFile(path.join(directory, `${name}_${version}.zip`), version); + } + const uploaded = []; + let uploading = false; + global.fetch = async (url, options) => { + if (url === `https://mods.factorio.com/api/mods/${name}`) { + return Response.json({ releases: [{ file_name: `${name}_1.0.2.zip` }] }); + } + const form = await new Request(url, options).formData(); + if (url.endsWith("/init_upload")) { + assert.equal(uploading, false, "previous upload must complete first"); + uploading = true; + return Response.json({ upload_url: "https://upload.example.test/release" }); + } + const file = form.get("file"); + uploaded.push([file.name, await file.text()]); + await new Promise(resolve => setImmediate(resolve)); + uploading = false; + return Response.json({ success: true }); + }; + await publishMods(directory); + assert.deepEqual(uploaded, [ + [`${name}_1.0.9.zip`, "1.0.9"], + [`${name}_1.0.10.zip`, "1.0.10"], + ]); + }); + + it("runs the CLI in dry-run mode without uploading unpublished archives", async function() { + const filename = "cli_publish_test_1.0.0.zip"; + await fs.writeFile(path.join(directory, filename), "dry-run archive"); + const mockFetch = ` + import assert from "node:assert/strict"; + global.fetch = async (url, options = {}) => { + assert.equal(url, "https://mods.factorio.com/api/mods/cli_publish_test"); + assert.equal(options.method ?? "GET", "GET", "dry-run must not upload"); + return Response.json({ releases: [] }); + }; + `; + const { stdout, stderr } = await execFileAsync(process.execPath, [ + "--import", `data:text/javascript,${encodeURIComponent(mockFetch)}`, + "publish.js", "factorio", directory, "--dry-run", + ]); + assert.equal(stderr, ""); + assert(stdout.includes("Requesting current releases of cli_publish_test")); + assert(stdout.includes(path.join(directory, filename))); + }); +});