Files
Multi-Debrid-Downloader/tests/extractor-jvm.test.ts
T
Sucukdeluxe 4202640904 fix(extraction): classify RAR directories without losing collision safety
Use WinRAR's directory-attribute listing as a counted type source so bare folder entries remain valid while file-directory aliases are still rejected.

Emit only removed for successfully deleted JVM partial outputs so asynchronous consumers preserve the original archive error instead of reporting a stale missing file.
2026-08-23 00:22:03 +02:00

746 lines
28 KiB
TypeScript

import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import AdmZip from "adm-zip";
import { afterEach, describe, expect, it } from "vitest";
import { extractPackageArchives } from "../src/main/extractor";
const tempDirs: string[] = [];
const originalBackend = process.env.RD_EXTRACT_BACKEND;
const require = createRequire(import.meta.url);
type ZipFixtureEntry = { name: string; directory?: boolean; content?: string };
type TargetTreeEntry = { path: string; type: "directory" | "file"; bytes?: string };
function readTargetTree(root: string): TargetTreeEntry[] {
const entries: TargetTreeEntry[] = [];
const visit = (directory: string): void => {
for (const name of fs.readdirSync(directory).sort((left, right) => left.localeCompare(right))) {
const absolutePath = path.join(directory, name);
const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
const stat = fs.lstatSync(absolutePath);
if (stat.isDirectory()) {
entries.push({ path: relativePath, type: "directory" });
visit(absolutePath);
} else {
entries.push({ path: relativePath, type: "file", bytes: fs.readFileSync(absolutePath).toString("base64") });
}
}
};
visit(root);
return entries;
}
function writeZipFixture(filePath: string, entries: readonly ZipFixtureEntry[]): void {
const ZipFile = require("adm-zip/zipFile") as new (input: null, options: Record<string, unknown>) => {
setEntry: (entry: unknown) => void;
compressToBuffer: () => Buffer;
};
const ZipEntry = require("adm-zip/zipEntry") as new (options: Record<string, unknown>) => {
entryName: string;
setData: (data: Buffer) => void;
};
const utils = require("adm-zip/util") as {
Constants: { NONE: number };
decoder: unknown;
};
const options = {
noSort: true,
readEntries: false,
method: utils.Constants.NONE,
decoder: utils.decoder
};
const zip = new ZipFile(null, options);
for (const fixture of entries) {
const entry = new ZipEntry(options);
entry.entryName = fixture.directory && !fixture.name.endsWith("/") ? `${fixture.name}/` : fixture.name;
entry.setData(Buffer.from(fixture.content || ""));
zip.setEntry(entry);
}
fs.writeFileSync(filePath, zip.compressToBuffer());
}
const jvmTargetCollisionCases = [
["directory then same-name file", [{ name: "same", directory: true }, { name: "same", content: "file" }]],
["file then same-name directory", [{ name: "same", content: "file" }, { name: "same", directory: true }]],
["parent file then child file", [{ name: "same", content: "parent" }, { name: "same/child", content: "child" }]],
["child file then parent file", [{ name: "same/child", content: "child" }, { name: "same", content: "parent" }]],
["upper-case then lower-case file aliases", [{ name: "Name", content: "first" }, { name: "name", content: "second" }]],
["lower-case then upper-case file aliases", [{ name: "name", content: "first" }, { name: "Name", content: "second" }]],
["upper-case then lower-case directory aliases", [{ name: "Folder", directory: true }, { name: "folder", directory: true }]],
["lower-case then upper-case directory aliases", [{ name: "folder", directory: true }, { name: "Folder", directory: true }]],
["duplicate file targets in forward order", [{ name: "same", content: "first" }, { name: "same", content: "second" }]],
["duplicate file targets in reverse order", [{ name: "same", content: "second" }, { name: "same", content: "first" }]]
] as const satisfies ReadonlyArray<readonly [string, readonly ZipFixtureEntry[]]>;
const rawPlanConflictModes = ["rename", "skip", "overwrite"] as const;
function hasJavaRuntime(): boolean {
const result = spawnSync("java", ["-version"], { stdio: "ignore" });
return result.status === 0;
}
function hasJvmExtractorRuntime(): boolean {
const root = path.join(process.cwd(), "resources", "extractor-jvm");
const classesMain = path.join(root, "classes", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.class");
const requiredLibs = [
path.join(root, "lib", "sevenzipjbinding.jar"),
path.join(root, "lib", "sevenzipjbinding-all-platforms.jar"),
path.join(root, "lib", "zip4j.jar")
];
return fs.existsSync(classesMain) && requiredLibs.every((libPath) => fs.existsSync(libPath));
}
function corruptFirstZipPayload(zipPath: string): void {
const bytes = fs.readFileSync(zipPath);
const signature = bytes.indexOf(Buffer.from([0x50, 0x4b, 0x03, 0x04]));
if (signature < 0) {
throw new Error("local ZIP header missing");
}
const compressedSize = bytes.readUInt32LE(signature + 18);
const nameLength = bytes.readUInt16LE(signature + 26);
const extraLength = bytes.readUInt16LE(signature + 28);
const dataOffset = signature + 30 + nameLength + extraLength;
if (compressedSize < 2 || dataOffset + compressedSize > bytes.length) {
throw new Error("ZIP payload missing");
}
bytes[dataOffset + Math.floor(compressedSize / 2)] ^= 0xff;
fs.writeFileSync(zipPath, bytes);
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
if (originalBackend === undefined) {
delete process.env.RD_EXTRACT_BACKEND;
} else {
process.env.RD_EXTRACT_BACKEND = originalBackend;
}
});
describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm backend", () => {
it("extracts zip archives through SevenZipJBinding backend", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-extract-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
const zipPath = path.join(packageDir, "release.zip");
const zip = new AdmZip();
zip.addFile("episode.txt", Buffer.from("ok"));
zip.writeZip(zipPath);
const events: import("../src/main/extractor").ExtractOutputEvent[] = [];
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
onOutput: (event) => events.push(event)
});
expect(result.extracted).toBe(1);
expect(result.failed).toBe(0);
expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true);
expect(events).toEqual([
expect.objectContaining({
version: 1,
archivePath: path.resolve(zipPath),
entryPath: "episode.txt",
outputPath: path.join(targetDir, "episode.txt"),
state: "opened",
disposition: "written"
}),
expect.objectContaining({
version: 1,
archivePath: path.resolve(zipPath),
entryPath: "episode.txt",
outputPath: path.join(targetDir, "episode.txt"),
state: "complete",
disposition: "written"
})
]);
expect(result.outputFiles).toEqual([path.join(targetDir, "episode.txt")]);
});
it.each(["7zjbinding", "zip4j"])("emits versioned Base64 output lines from %s", (backend) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-output-${backend}-`));
tempDirs.push(root);
const targetDir = path.join(root, "out");
const zipPath = path.join(root, "release.zip");
const zip = new AdmZip();
zip.addFile("folder/episode.txt", Buffer.from("ok"));
zip.writeZip(zipPath);
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
const classPath = [
path.join(runtimeRoot, "classes"),
path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"),
path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"),
path.join(runtimeRoot, "lib", "zip4j.jar")
].join(path.delimiter);
const run = spawnSync("java", [
"-cp",
classPath,
"com.sucukdeluxe.extractor.JBindExtractorMain",
"--archive",
zipPath,
"--target",
targetDir,
"--conflict",
"overwrite",
"--backend",
backend
], { encoding: "utf8" });
expect(run.status).toBe(0);
const outputLines = String(run.stdout).split(/\r?\n/).filter((line) => line.startsWith("RD_OUTPUT "));
expect(outputLines.map((line) => line.split(" ")[2])).toEqual(["opened", "complete"]);
const fields = outputLines[1].split(" ");
expect(fields.slice(0, 5)).toEqual(["RD_OUTPUT", "1", "complete", "written", fields[4]]);
expect(Buffer.from(fields[4], "base64").toString("utf8")).toBe(path.resolve(zipPath));
expect(Buffer.from(fields[5], "base64").toString("utf8")).toBe("folder/episode.txt");
expect(Buffer.from(fields[6], "base64").toString("utf8")).toBe(path.join(targetDir, "folder", "episode.txt"));
});
it.each(["7zjbinding", "zip4j"])("rejects %s output behind a junction before opening it", (backend) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-junction-${backend}-`));
tempDirs.push(root);
const targetDir = path.join(root, "out");
const realDir = path.join(targetDir, "real");
const linkedDir = path.join(targetDir, "linked");
fs.mkdirSync(realDir, { recursive: true });
try {
fs.symlinkSync(realDir, linkedDir, process.platform === "win32" ? "junction" : "dir");
} catch {
return;
}
const protectedPath = path.join(realDir, "protected.txt");
fs.writeFileSync(protectedPath, "foreign");
const zipPath = path.join(root, "release.zip");
const zip = new AdmZip();
zip.addFile("linked/protected.txt", Buffer.from("package"));
zip.writeZip(zipPath);
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
const classPath = [
path.join(runtimeRoot, "classes"),
path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"),
path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"),
path.join(runtimeRoot, "lib", "zip4j.jar")
].join(path.delimiter);
const run = spawnSync("java", [
"-cp",
classPath,
"com.sucukdeluxe.extractor.JBindExtractorMain",
"--archive",
zipPath,
"--target",
targetDir,
"--conflict",
"overwrite",
"--backend",
backend
], { encoding: "utf8" });
expect(run.status).not.toBe(0);
expect(fs.readFileSync(protectedPath, "utf8")).toBe("foreign");
});
it("turns JVM output callback failures into a controlled archive failure and keeps the next request usable", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-callback-"));
tempDirs.push(root);
const firstPackage = path.join(root, "first-pkg");
const secondPackage = path.join(root, "second-pkg");
fs.mkdirSync(firstPackage, { recursive: true });
fs.mkdirSync(secondPackage, { recursive: true });
const firstZip = new AdmZip();
firstZip.addFile("first.txt", Buffer.from("first"));
firstZip.writeZip(path.join(firstPackage, "first.zip"));
const secondZip = new AdmZip();
secondZip.addFile("second.txt", Buffer.from("second"));
secondZip.writeZip(path.join(secondPackage, "second.zip"));
const first = await extractPackageArchives({
packageDir: firstPackage,
targetDir: path.join(root, "first-out"),
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
onOutput: () => {
throw new Error("jvm-output-callback-failed");
}
});
const second = await extractPackageArchives({
packageDir: secondPackage,
targetDir: path.join(root, "second-out"),
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false
});
expect(first.extracted).toBe(0);
expect(first.failed).toBe(1);
expect(first.lastError).toContain("jvm-output-callback-failed");
expect(second).toEqual(expect.objectContaining({ extracted: 1, failed: 0 }));
}, 10000);
it.each(["7zjbinding", "zip4j"])("reports only removed after deleting a failed %s output", (backend) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-partial-${backend}-`));
tempDirs.push(root);
const targetDir = path.join(root, "out");
const zipPath = path.join(root, "corrupt.zip");
const zip = new AdmZip();
zip.addFile("episode.bin", Buffer.from("payload-".repeat(20_000)));
zip.writeZip(zipPath);
corruptFirstZipPayload(zipPath);
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
const classPath = [
path.join(runtimeRoot, "classes"),
path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"),
path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"),
path.join(runtimeRoot, "lib", "zip4j.jar")
].join(path.delimiter);
const run = spawnSync("java", [
"-cp",
classPath,
"com.sucukdeluxe.extractor.JBindExtractorMain",
"--archive",
zipPath,
"--target",
targetDir,
"--conflict",
"overwrite",
"--backend",
backend
], { encoding: "utf8" });
const states = String(run.stdout)
.split(/\r?\n/)
.filter((line) => line.startsWith("RD_OUTPUT "))
.map((line) => line.split(" ")[2]);
expect(run.status).not.toBe(0);
expect(states).toEqual(["opened", "removed"]);
expect(fs.existsSync(path.join(targetDir, "episode.bin"))).toBe(false);
});
it("preserves the real JVM archive error when a failed output is removed before Node consumes the event", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-removed-output-error-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
const zipPath = path.join(packageDir, "corrupt.zip");
fs.mkdirSync(packageDir, { recursive: true });
const zip = new AdmZip();
zip.addFile("episode.bin", Buffer.from("payload-".repeat(20_000)));
zip.writeZip(zipPath);
corruptFirstZipPayload(zipPath);
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false
});
expect(result).toEqual(expect.objectContaining({ extracted: 0, failed: 1 }));
expect(result.lastError).not.toContain("Gemeldete Extract-Ausgabe existiert nicht");
expect(result.lastError).toMatch(/crc|data|checksum|zip|archive/i);
expect(fs.existsSync(path.join(targetDir, "episode.bin"))).toBe(false);
});
it.each([
["7zjbinding", "file.mkv:stream", "file.mkv"],
["7zjbinding", "name.", "name"],
["7zjbinding", "name ", "name"],
["7zjbinding", "CON", "safe-base.txt"],
["7zjbinding", "aux.txt", "safe-base.txt"],
["7zjbinding", "folder/LPT1.mkv", "safe-base.txt"],
["zip4j", "file.mkv:stream", "file.mkv"],
["zip4j", "name.", "name"],
["zip4j", "name ", "name"],
["zip4j", "CON", "safe-base.txt"],
["zip4j", "aux.txt", "safe-base.txt"],
["zip4j", "folder/LPT1.mkv", "safe-base.txt"]
] as const)("rejects %s Win32-unsafe entry %s before changing its alias", (backend, entryName, baseName) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-win32-${backend}-`));
tempDirs.push(root);
const targetDir = path.join(root, "out");
fs.mkdirSync(targetDir, { recursive: true });
const basePath = path.join(targetDir, baseName);
fs.writeFileSync(basePath, "foreign");
const zipPath = path.join(root, "unsafe.zip");
const zip = new AdmZip();
zip.addFile(entryName, Buffer.from("package"));
zip.writeZip(zipPath);
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
const classPath = [
path.join(runtimeRoot, "classes"),
path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"),
path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"),
path.join(runtimeRoot, "lib", "zip4j.jar")
].join(path.delimiter);
const run = spawnSync("java", [
"-cp",
classPath,
"com.sucukdeluxe.extractor.JBindExtractorMain",
"--archive",
zipPath,
"--target",
targetDir,
"--conflict",
"overwrite",
"--backend",
backend
], { encoding: "utf8" });
expect(run.status).not.toBe(0);
expect(fs.readFileSync(basePath, "utf8")).toBe("foreign");
});
it("reconciles a real aborted JVM opened output to partial or removed", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-abort-output-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
const zip = new AdmZip();
zip.addFile("episode.bin", Buffer.alloc(8 * 1024 * 1024, 7));
zip.writeZip(path.join(packageDir, "large.zip"));
const controller = new AbortController();
const events: import("../src/main/extractor").ExtractOutputEvent[] = [];
const extraction = extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
signal: controller.signal,
onOutput: (event) => {
events.push(event);
if (event.state === "opened") {
controller.abort();
}
}
});
await expect(extraction).rejects.toThrow("aborted:extract");
expect(events[0]?.state).toBe("opened");
expect(["partial", "removed"]).toContain(events[events.length - 1]?.state);
const outputPath = path.join(targetDir, "episode.bin");
if (events[events.length - 1]?.state === "partial") {
expect(fs.statSync(outputPath).isFile()).toBe(true);
} else {
expect(fs.existsSync(outputPath)).toBe(false);
}
await new Promise((resolve) => setTimeout(resolve, 500));
}, 10000);
it.each(["7zjbinding", "zip4j"])("preflights every %s entry before overwriting an earlier safe target", (backend) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-full-preflight-${backend}-`));
tempDirs.push(root);
const targetDir = path.join(root, "out");
fs.mkdirSync(targetDir, { recursive: true });
const safePath = path.join(targetDir, "00-safe.txt");
const aliasPath = path.join(targetDir, "zz-name");
fs.writeFileSync(safePath, "foreign-safe");
fs.writeFileSync(aliasPath, "foreign-alias");
const zipPath = path.join(root, "preflight.zip");
const zip = new AdmZip();
zip.addFile("00-safe.txt", Buffer.from("package-safe"));
zip.addFile("zz-name.", Buffer.from("package-invalid"));
zip.writeZip(zipPath);
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
const classPath = [
path.join(runtimeRoot, "classes"),
path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"),
path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"),
path.join(runtimeRoot, "lib", "zip4j.jar")
].join(path.delimiter);
const run = spawnSync("java", [
"-cp",
classPath,
"com.sucukdeluxe.extractor.JBindExtractorMain",
"--archive",
zipPath,
"--target",
targetDir,
"--conflict",
"overwrite",
"--backend",
backend
], { encoding: "utf8" });
expect(run.status).not.toBe(0);
expect(fs.readFileSync(safePath, "utf8")).toBe("foreign-safe");
expect(fs.readFileSync(aliasPath, "utf8")).toBe("foreign-alias");
});
it.each(["7zjbinding", "zip4j"].flatMap((backend) => rawPlanConflictModes.flatMap((conflictMode) => jvmTargetCollisionCases.map(([label, entries]) => [backend, conflictMode, label, entries] as const))))(
"rejects %s %s %s before any target mutation",
(backend, conflictMode, _label, entries) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-target-plan-${backend}-`));
tempDirs.push(root);
const targetDir = path.join(root, "out");
fs.mkdirSync(targetDir, { recursive: true });
const sentinelPath = path.join(targetDir, "sentinel.txt");
fs.writeFileSync(sentinelPath, "foreign");
const before = readTargetTree(targetDir);
const zipPath = path.join(root, "collision.zip");
writeZipFixture(zipPath, entries);
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
const classPath = [
path.join(runtimeRoot, "classes"),
path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"),
path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"),
path.join(runtimeRoot, "lib", "zip4j.jar")
].join(path.delimiter);
const run = spawnSync("java", [
"-cp",
classPath,
"com.sucukdeluxe.extractor.JBindExtractorMain",
"--archive",
zipPath,
"--target",
targetDir,
"--conflict",
conflictMode,
"--backend",
backend
], { encoding: "utf8" });
expect(run.status).not.toBe(0);
expect(readTargetTree(targetDir)).toEqual(before);
}
);
it.each(["7zjbinding", "zip4j"].flatMap((backend) => rawPlanConflictModes.map((conflictMode) => [backend, conflictMode] as const)))("allows safely identical duplicate directory targets in %s with %s", (backend, conflictMode) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-identical-directory-${backend}-`));
tempDirs.push(root);
const targetDir = path.join(root, "out");
const zipPath = path.join(root, "directories.zip");
writeZipFixture(zipPath, [
{ name: "same", directory: true },
{ name: "same", directory: true }
]);
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
const classPath = [
path.join(runtimeRoot, "classes"),
path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"),
path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"),
path.join(runtimeRoot, "lib", "zip4j.jar")
].join(path.delimiter);
const run = spawnSync("java", [
"-cp",
classPath,
"com.sucukdeluxe.extractor.JBindExtractorMain",
"--archive",
zipPath,
"--target",
targetDir,
"--conflict",
conflictMode,
"--backend",
backend
], { encoding: "utf8" });
expect(run.status).toBe(0);
expect(fs.readdirSync(targetDir)).toEqual(["same"]);
expect(fs.statSync(path.join(targetDir, "same")).isDirectory()).toBe(true);
});
it.each(["7zjbinding", "zip4j"].flatMap((backend) => rawPlanConflictModes.map((conflictMode) => [backend, conflictMode] as const)))(
"preserves existing-target %s behavior for %s",
(backend, conflictMode) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-jvm-existing-target-${backend}-${conflictMode}-`));
tempDirs.push(root);
const targetDir = path.join(root, "out");
fs.mkdirSync(targetDir, { recursive: true });
fs.writeFileSync(path.join(targetDir, "same.txt"), "old");
const zipPath = path.join(root, "existing-target.zip");
writeZipFixture(zipPath, [{ name: "same.txt", content: "new" }]);
const runtimeRoot = path.join(process.cwd(), "resources", "extractor-jvm");
const classPath = [
path.join(runtimeRoot, "classes"),
path.join(runtimeRoot, "lib", "sevenzipjbinding.jar"),
path.join(runtimeRoot, "lib", "sevenzipjbinding-all-platforms.jar"),
path.join(runtimeRoot, "lib", "zip4j.jar")
].join(path.delimiter);
const run = spawnSync("java", [
"-cp",
classPath,
"com.sucukdeluxe.extractor.JBindExtractorMain",
"--archive",
zipPath,
"--target",
targetDir,
"--conflict",
conflictMode,
"--backend",
backend
], { encoding: "utf8" });
expect(run.status).toBe(0);
expect(fs.readFileSync(path.join(targetDir, "same.txt"), "utf8")).toBe(conflictMode === "overwrite" ? "new" : "old");
if (conflictMode === "rename") {
expect(fs.readFileSync(path.join(targetDir, "same (1).txt"), "utf8")).toBe("new");
} else {
expect(fs.existsSync(path.join(targetDir, "same (1).txt"))).toBe(false);
}
}
);
it("emits progress callbacks with archiveName and percent", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-progress-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
const zipPath = path.join(packageDir, "progress-test.zip");
const zip = new AdmZip();
zip.addFile("file1.txt", Buffer.from("Hello World ".repeat(100)));
zip.addFile("file2.txt", Buffer.from("Another file ".repeat(100)));
zip.writeZip(zipPath);
const progressUpdates: Array<{
archiveName: string;
percent: number;
phase: string;
archivePercent?: number;
}> = [];
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
onProgress: (update) => {
progressUpdates.push({
archiveName: update.archiveName,
percent: update.percent,
phase: update.phase,
archivePercent: update.archivePercent,
});
},
});
expect(result.extracted).toBe(1);
expect(result.failed).toBe(0);
const phases = new Set(progressUpdates.map((u) => u.phase));
expect(phases.has("preparing")).toBe(true);
expect(phases.has("extracting")).toBe(true);
const extracting = progressUpdates.filter((u) => u.phase === "extracting" && u.archiveName === "progress-test.zip");
expect(extracting.length).toBeGreaterThan(0);
const lastExtracting = extracting[extracting.length - 1];
expect(lastExtracting.archivePercent).toBe(100);
expect(fs.existsSync(path.join(targetDir, "file1.txt"))).toBe(true);
expect(fs.existsSync(path.join(targetDir, "file2.txt"))).toBe(true);
});
it("extracts multiple archives sequentially with progress for each", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-multi-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
const zip1 = new AdmZip();
zip1.addFile("episode01.txt", Buffer.from("ep1 content"));
zip1.writeZip(path.join(packageDir, "archive1.zip"));
const zip2 = new AdmZip();
zip2.addFile("episode02.txt", Buffer.from("ep2 content"));
zip2.writeZip(path.join(packageDir, "archive2.zip"));
const archiveNames = new Set<string>();
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
onProgress: (update) => {
if (update.phase === "extracting" && update.archiveName) {
archiveNames.add(update.archiveName);
}
},
});
expect(result.extracted).toBe(2);
expect(result.failed).toBe(0);
expect(archiveNames.has("archive1.zip")).toBe(true);
expect(archiveNames.has("archive2.zip")).toBe(true);
expect(fs.existsSync(path.join(targetDir, "episode01.txt"))).toBe(true);
expect(fs.existsSync(path.join(targetDir, "episode02.txt"))).toBe(true);
});
it("respects ask/skip conflict mode in jvm backend", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-extract-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
fs.mkdirSync(targetDir, { recursive: true });
const zipPath = path.join(packageDir, "conflict.zip");
const zip = new AdmZip();
zip.addFile("same.txt", Buffer.from("new"));
zip.writeZip(zipPath);
const existingPath = path.join(targetDir, "same.txt");
fs.writeFileSync(existingPath, "old", "utf8");
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "ask",
removeLinks: false,
removeSamples: false
});
expect(result.extracted).toBe(1);
expect(result.failed).toBe(0);
expect(fs.readFileSync(existingPath, "utf8")).toBe("old");
});
});