Release Archive Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assessArchivePath } from "../../src/archive/paths";
|
||||
|
||||
describe("archive path policy", () => {
|
||||
it.each([
|
||||
"../escape.txt",
|
||||
"/absolute.txt",
|
||||
"C:/drive.txt",
|
||||
"safe/../../escape",
|
||||
"CON",
|
||||
"file.txt:stream",
|
||||
"trailing. ",
|
||||
"invoice\u202egnp.exe",
|
||||
])("blocks unsafe path %s", (path) =>
|
||||
expect(assessArchivePath(path).safe).toBe(false),
|
||||
);
|
||||
|
||||
it("normalizes Unicode and treats backslashes as cross-platform separators", () => {
|
||||
const result = assessArchivePath("Folder\\cafe\u0301.txt");
|
||||
expect(result.normalized).toBe("Folder/café.txt");
|
||||
expect(result.safe).toBe(true);
|
||||
expect(result.issues.map((issue) => issue.code)).toContain(
|
||||
"BACKSLASH_PATH",
|
||||
);
|
||||
});
|
||||
|
||||
it("produces a conservative case-insensitive collision key", () => {
|
||||
expect(assessArchivePath("Folder/Report.TXT").collisionKey).toBe(
|
||||
assessArchivePath("folder/report.txt").collisionKey,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment node
|
||||
import { File as NodeFile } from "node:buffer";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { previewEntry } from "../../src/archive/preview";
|
||||
import type {
|
||||
ArchiveDocument,
|
||||
ArchiveEntryRecord,
|
||||
} from "../../src/archive/types";
|
||||
|
||||
describe("bounded previews", () => {
|
||||
it("returns a safe static PNG Blob only after dimension inspection", async () => {
|
||||
const png = Uint8Array.from(
|
||||
Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
),
|
||||
);
|
||||
await expect(previewFixture("pixel.png", png)).resolves.toMatchObject({
|
||||
kind: "image",
|
||||
mimeType: "image/png",
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses animated PNG previews before browser decoding", async () => {
|
||||
const png = Uint8Array.from(
|
||||
Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
),
|
||||
);
|
||||
const idat = findChunk(png, "IDAT");
|
||||
const actl = new Uint8Array([
|
||||
0, 0, 0, 8, 0x61, 0x63, 0x54, 0x4c, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
]);
|
||||
const animated = new Uint8Array(png.length + actl.length);
|
||||
animated.set(png.subarray(0, idat), 0);
|
||||
animated.set(actl, idat);
|
||||
animated.set(png.subarray(idat), idat + actl.length);
|
||||
await expect(previewFixture("animated.png", animated)).rejects.toThrow(
|
||||
/Animated/iu,
|
||||
);
|
||||
});
|
||||
|
||||
it("supports a bounded VP8X WebP inventory without interpreting active content", async () => {
|
||||
const webp = new Uint8Array(30);
|
||||
webp.set(new TextEncoder().encode("RIFF"), 0);
|
||||
new DataView(webp.buffer).setUint32(4, 22, true);
|
||||
webp.set(new TextEncoder().encode("WEBPVP8X"), 8);
|
||||
new DataView(webp.buffer).setUint32(16, 10, true);
|
||||
webp[24] = 2;
|
||||
webp[27] = 3;
|
||||
await expect(previewFixture("sample.webp", webp)).resolves.toMatchObject({
|
||||
kind: "image",
|
||||
width: 3,
|
||||
height: 4,
|
||||
mimeType: "image/webp",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows nested archives as bytes without recursive expansion", async () => {
|
||||
const nested = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 1, 2, 3]);
|
||||
await expect(previewFixture("nested.zip", nested)).resolves.toMatchObject({
|
||||
kind: "hex",
|
||||
note: expect.stringMatching(/Nested archive expansion/iu),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function documentFor(path: string, payload: Uint8Array): ArchiveDocument {
|
||||
const entry: ArchiveEntryRecord = {
|
||||
id: "0",
|
||||
sourceIndex: 0,
|
||||
rawPath: path,
|
||||
path,
|
||||
collisionKey: path.toLocaleLowerCase("en-US"),
|
||||
kind: "file",
|
||||
size: payload.length,
|
||||
extractable: true,
|
||||
issues: [],
|
||||
dataOffset: 0,
|
||||
};
|
||||
return {
|
||||
name: "fixture.gz",
|
||||
format: "gzip",
|
||||
source: new NodeFile(
|
||||
[Buffer.from(payload)],
|
||||
"fixture.gz",
|
||||
) as unknown as File,
|
||||
sourceBytes: payload.length,
|
||||
entries: [entry],
|
||||
issues: [],
|
||||
expandedBytes: payload.length,
|
||||
compressedBytes: payload.length,
|
||||
zip64: false,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function previewFixture(path: string, payload: Uint8Array) {
|
||||
const document = documentFor(path, payload);
|
||||
return previewEntry(document, document.entries[0]!);
|
||||
}
|
||||
|
||||
function findChunk(bytes: Uint8Array, type: string): number {
|
||||
const expected = new TextEncoder().encode(type);
|
||||
for (let offset = 8; offset + 8 <= bytes.length;) {
|
||||
if (expected.every((value, index) => bytes[offset + 4 + index] === value))
|
||||
return offset;
|
||||
const length = new DataView(
|
||||
bytes.buffer,
|
||||
bytes.byteOffset + offset,
|
||||
4,
|
||||
).getUint32(0);
|
||||
offset += 12 + length;
|
||||
}
|
||||
throw new Error(`${type} chunk not found`);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment node
|
||||
import { File } from "node:buffer";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { gzipDeterministic, gunzipBounded } from "../../src/archive/gzip";
|
||||
import { createTar, parseTar } from "../../src/archive/tar";
|
||||
|
||||
function input(path: string, text: string) {
|
||||
return {
|
||||
path,
|
||||
file: new File(
|
||||
[text],
|
||||
path.split("/").at(-1)!,
|
||||
) as unknown as globalThis.File,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TAR, USTAR, PAX and gzip", () => {
|
||||
it("creates deterministic TAR and parses regular entries", async () => {
|
||||
const inputs = [input("b.txt", "second"), input("folder/a.txt", "first")];
|
||||
const first = await createTar(inputs);
|
||||
const second = await createTar(inputs);
|
||||
expect(first).toEqual(second);
|
||||
const parsed = parseTar(first);
|
||||
expect(parsed.entries.map((entry) => entry.path)).toEqual([
|
||||
"b.txt",
|
||||
"folder/a.txt",
|
||||
]);
|
||||
expect(parsed.entries.every((entry) => entry.extractable)).toBe(true);
|
||||
expect(parsed.entries[1]?.crc32).toBe("9271ee57");
|
||||
});
|
||||
|
||||
it("round-trips a long path through a bounded PAX path header", async () => {
|
||||
const longPath = `${"long-".repeat(24)}name.txt`;
|
||||
const tar = await createTar([input(longPath, "PAX")]);
|
||||
expect(parseTar(tar).entries[0]?.path).toBe(longPath);
|
||||
});
|
||||
|
||||
it("lists but blocks symbolic links", async () => {
|
||||
const tar = await createTar([input("link", "target")]);
|
||||
tar[156] = 0x32;
|
||||
rewriteChecksum(tar.subarray(0, 512));
|
||||
const entry = parseTar(tar).entries[0]!;
|
||||
expect(entry.kind).toBe("symlink");
|
||||
expect(entry.extractable).toBe(false);
|
||||
expect(entry.issues.map((issue) => issue.code)).toContain("SPECIAL_ENTRY");
|
||||
});
|
||||
|
||||
it("rejects a damaged TAR header checksum", async () => {
|
||||
const tar = await createTar([input("file.txt", "data")]);
|
||||
tar[0] = tar[0]! ^ 1;
|
||||
expect(() => parseTar(tar)).toThrow(/checksum/iu);
|
||||
});
|
||||
|
||||
it("rejects ambiguous non-zero data after the TAR end marker", async () => {
|
||||
const tar = await createTar([input("file.txt", "data")]);
|
||||
const appended = new Uint8Array(tar.length + 1);
|
||||
appended.set(tar);
|
||||
appended[tar.length] = 1;
|
||||
expect(() => parseTar(appended)).toThrow(/follows the TAR end/iu);
|
||||
});
|
||||
|
||||
it("creates deterministic gzip and validates CRC and ISIZE", () => {
|
||||
const source = new TextEncoder().encode("local archive payload");
|
||||
const first = gzipDeterministic(source);
|
||||
expect(first).toEqual(gzipDeterministic(source));
|
||||
expect(gunzipBounded(first, 1024)).toEqual(source);
|
||||
first[first.length - 8] = first[first.length - 8]! ^ 1;
|
||||
expect(() => gunzipBounded(first, 1024)).toThrow(/CRC-32|footer/iu);
|
||||
});
|
||||
|
||||
it("stops gzip expansion at the configured operation limit", () => {
|
||||
const compressed = gzipDeterministic(new Uint8Array(32_768));
|
||||
expect(() => gunzipBounded(compressed, 1024)).toThrow(/expanded-byte/iu);
|
||||
});
|
||||
|
||||
it("explicitly rejects concatenated gzip members", () => {
|
||||
const left = gzipDeterministic(new TextEncoder().encode("left"));
|
||||
const right = gzipDeterministic(new TextEncoder().encode("right"));
|
||||
const joined = new Uint8Array(left.length + right.length);
|
||||
joined.set(left);
|
||||
joined.set(right, left.length);
|
||||
expect(() => gunzipBounded(joined, 1024)).toThrow(/multi-member/iu);
|
||||
});
|
||||
});
|
||||
|
||||
function rewriteChecksum(header: Uint8Array) {
|
||||
header.fill(0x20, 148, 156);
|
||||
const checksum = header.reduce((sum, byte) => sum + byte, 0);
|
||||
const text = checksum.toString(8).padStart(6, "0");
|
||||
header.set(new TextEncoder().encode(text), 148);
|
||||
header[154] = 0;
|
||||
header[155] = 0x20;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// @vitest-environment node
|
||||
import { File as NodeFile } from "node:buffer";
|
||||
import { BlobReader, BlobWriter, TextReader, ZipWriter } from "@zip.js/zip.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { compareArchives } from "../../src/archive/compare";
|
||||
import { previewEntry } from "../../src/archive/preview";
|
||||
import {
|
||||
createArchive,
|
||||
createSafeSelectionZip,
|
||||
inspectArchive,
|
||||
readEntryBytes,
|
||||
} from "../../src/archive/service";
|
||||
|
||||
const asFile = (
|
||||
parts: ConstructorParameters<typeof NodeFile>[0],
|
||||
name: string,
|
||||
type = "application/octet-stream",
|
||||
) => new NodeFile(parts, name, { type }) as unknown as globalThis.File;
|
||||
|
||||
describe("ZIP inspection and safe workflows", () => {
|
||||
it("creates byte-deterministic ZIPs, reads CRC-checked content and previews text", async () => {
|
||||
const files = [
|
||||
asFile(["hello"], "hello.txt"),
|
||||
asFile(["world"], "world.txt"),
|
||||
];
|
||||
const first = await createArchive(files, "zip");
|
||||
const second = await createArchive(files, "zip");
|
||||
expect(new Uint8Array(await first.arrayBuffer())).toEqual(
|
||||
new Uint8Array(await second.arrayBuffer()),
|
||||
);
|
||||
|
||||
const document = await inspectArchive(
|
||||
asFile([await first.arrayBuffer()], "sample.zip"),
|
||||
);
|
||||
expect(document.entries).toHaveLength(2);
|
||||
expect(document.entries.every((entry) => entry.extractable)).toBe(true);
|
||||
const bytes = await readEntryBytes(document, document.entries[0]!, 1024);
|
||||
expect(new TextDecoder().decode(bytes)).toBe("hello");
|
||||
await expect(
|
||||
previewEntry(document, document.entries[0]!),
|
||||
).resolves.toMatchObject({ kind: "text", text: "hello" });
|
||||
});
|
||||
|
||||
it("blocks traversal, case-colliding and encrypted entries at inspection", async () => {
|
||||
const blobWriter = new BlobWriter("application/zip");
|
||||
const writer = new ZipWriter(blobWriter, { useWebWorkers: false });
|
||||
await writer.add("../escape.txt", new TextReader("bad"), {
|
||||
useWebWorkers: false,
|
||||
});
|
||||
await writer.add("Report.txt", new TextReader("one"), {
|
||||
useWebWorkers: false,
|
||||
});
|
||||
await writer.add("report.TXT", new TextReader("two"), {
|
||||
useWebWorkers: false,
|
||||
});
|
||||
await writer.add("secret.txt", new TextReader("secret"), {
|
||||
password: "test-password",
|
||||
useWebWorkers: false,
|
||||
});
|
||||
const blob = await writer.close();
|
||||
const document = await inspectArchive(
|
||||
asFile([await blob.arrayBuffer()], "unsafe.zip"),
|
||||
);
|
||||
expect(document.entries[0]?.issues.map((issue) => issue.code)).toContain(
|
||||
"PATH_TRAVERSAL",
|
||||
);
|
||||
expect(document.entries[2]?.issues.map((issue) => issue.code)).toContain(
|
||||
"DUPLICATE_PATH",
|
||||
);
|
||||
expect(document.entries[3]?.issues.map((issue) => issue.code)).toContain(
|
||||
"ENCRYPTED_ENTRY",
|
||||
);
|
||||
expect(document.entries.filter((entry) => entry.extractable)).toEqual([]);
|
||||
});
|
||||
|
||||
it("enforces compression-ratio policy before decompression", async () => {
|
||||
const blob = await createArchive(
|
||||
[asFile([new Uint8Array(1024 * 1024)], "zeros.bin")],
|
||||
"zip",
|
||||
);
|
||||
const document = await inspectArchive(
|
||||
asFile([await blob.arrayBuffer()], "ratio.zip"),
|
||||
);
|
||||
expect(document.entries[0]?.extractable).toBe(false);
|
||||
expect(document.entries[0]?.issues.map((issue) => issue.code)).toContain(
|
||||
"COMPRESSION_RATIO",
|
||||
);
|
||||
});
|
||||
|
||||
it("detects stored-entry corruption when content is requested", async () => {
|
||||
const writer = new ZipWriter(new BlobWriter("application/zip"), {
|
||||
level: 0,
|
||||
useWebWorkers: false,
|
||||
});
|
||||
await writer.add("hello.txt", new TextReader("hello"), {
|
||||
level: 0,
|
||||
useWebWorkers: false,
|
||||
});
|
||||
const blob = await writer.close();
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
const nameLength = bytes[26]! | (bytes[27]! << 8);
|
||||
const extraLength = bytes[28]! | (bytes[29]! << 8);
|
||||
bytes[30 + nameLength + extraLength] =
|
||||
bytes[30 + nameLength + extraLength]! ^ 1;
|
||||
const document = await inspectArchive(asFile([bytes], "corrupt.zip"));
|
||||
await expect(
|
||||
readEntryBytes(document, document.entries[0]!, 1024),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("repackages only a verified selection into a fresh safe ZIP", async () => {
|
||||
const original = await createArchive(
|
||||
[asFile(["one"], "one.txt"), asFile(["two"], "two.txt")],
|
||||
"zip",
|
||||
);
|
||||
const document = await inspectArchive(
|
||||
asFile([await original.arrayBuffer()], "original.zip"),
|
||||
);
|
||||
const safe = await createSafeSelectionZip(document, [
|
||||
document.entries[1]!.id,
|
||||
]);
|
||||
const repackaged = await inspectArchive(
|
||||
asFile([await safe.arrayBuffer()], "safe.zip"),
|
||||
);
|
||||
expect(repackaged.entries.map((entry) => entry.path)).toEqual(["two.txt"]);
|
||||
expect(
|
||||
new TextDecoder().decode(
|
||||
await readEntryBytes(repackaged, repackaged.entries[0]!, 1024),
|
||||
),
|
||||
).toBe("two");
|
||||
});
|
||||
|
||||
it("compares archive content independent of container format", async () => {
|
||||
const leftBlob = await createArchive(
|
||||
[asFile(["same"], "same.txt"), asFile(["old"], "changed.txt")],
|
||||
"zip",
|
||||
);
|
||||
const rightBlob = await createArchive(
|
||||
[asFile(["same"], "same.txt"), asFile(["new"], "changed.txt")],
|
||||
"tar",
|
||||
);
|
||||
const left = await inspectArchive(
|
||||
asFile([await leftBlob.arrayBuffer()], "left.zip"),
|
||||
);
|
||||
const right = await inspectArchive(
|
||||
asFile([await rightBlob.arrayBuffer()], "right.tar"),
|
||||
);
|
||||
expect(
|
||||
compareArchives(left, right).map(({ path, status }) => ({
|
||||
path,
|
||||
status,
|
||||
})),
|
||||
).toEqual([
|
||||
{ path: "changed.txt", status: "changed" },
|
||||
{ path: "same.txt", status: "same" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads a ZIP produced externally through a BlobReader", async () => {
|
||||
const writer = new ZipWriter(new BlobWriter("application/zip"), {
|
||||
useWebWorkers: false,
|
||||
});
|
||||
await writer.add(
|
||||
"folder/data.bin",
|
||||
new BlobReader(new Blob([new Uint8Array([0, 1, 2, 3])])),
|
||||
{ useWebWorkers: false },
|
||||
);
|
||||
const document = await inspectArchive(
|
||||
asFile([await (await writer.close()).arrayBuffer()], "external.zip"),
|
||||
);
|
||||
expect(document.entries[0]).toMatchObject({
|
||||
path: "folder/data.bin",
|
||||
size: 4,
|
||||
compression: "deflate",
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes ZIP64 metadata without requiring a multi-gigabyte fixture", async () => {
|
||||
const writer = new ZipWriter(new BlobWriter("application/zip"), {
|
||||
useWebWorkers: false,
|
||||
zip64: true,
|
||||
});
|
||||
await writer.add("zip64.txt", new TextReader("small fixture"), {
|
||||
useWebWorkers: false,
|
||||
zip64: true,
|
||||
});
|
||||
const document = await inspectArchive(
|
||||
asFile([await (await writer.close()).arrayBuffer()], "forced-zip64.zip"),
|
||||
);
|
||||
expect(document.zip64).toBe(true);
|
||||
expect(document.entries[0]?.zip64).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { strToU8, zipSync } from "fflate";
|
||||
|
||||
const ORIGIN = "http://127.0.0.1:4173";
|
||||
async function localOnly(page: Page) {
|
||||
const external: string[] = [];
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== ORIGIN) {
|
||||
external.push(url.href);
|
||||
await route.abort();
|
||||
} else await route.continue();
|
||||
});
|
||||
return external;
|
||||
}
|
||||
|
||||
test("runs from a nested path without external requests", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
const external = await localOnly(page);
|
||||
await page.goto("/deep/nested/archive/");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Archive Tools" }),
|
||||
).toBeVisible();
|
||||
expect(external).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("serves the release identity and hardened headers", async ({
|
||||
request,
|
||||
}) => {
|
||||
const index = await request.get("/deep/nested/archive/");
|
||||
expect(index.ok()).toBe(true);
|
||||
expect(index.headers()["content-security-policy"]).toContain(
|
||||
"default-src 'self'",
|
||||
);
|
||||
expect(await index.text()).not.toMatch(/\b(?:src|href)=["']\//u);
|
||||
const manifest = await request.get("/deep/nested/archive/toolbox-app.json");
|
||||
await expect(manifest.json()).resolves.toMatchObject({
|
||||
id: "de.add-ideas.archive-tools",
|
||||
version: "0.1.0",
|
||||
entry: "./",
|
||||
requirements: { workers: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("inspects, previews and safely repackages a ZIP locally", async ({
|
||||
page,
|
||||
}) => {
|
||||
const external = await localOnly(page);
|
||||
await page.goto("/deep/nested/archive/");
|
||||
const archive = zipSync({
|
||||
"hello.txt": strToU8("Hello from a local ZIP"),
|
||||
"folder/data.bin": new Uint8Array([0, 1, 2, 3]),
|
||||
});
|
||||
await page.getByLabel("Choose archive").setInputFiles({
|
||||
name: "sample.zip",
|
||||
mimeType: "application/zip",
|
||||
buffer: Buffer.from(archive),
|
||||
});
|
||||
await expect(page.getByText("Inspected 2 entries locally.")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "hello.txt" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "hello.txt" }).click();
|
||||
await expect(page.getByText("Hello from a local ZIP")).toBeVisible();
|
||||
await page.getByRole("checkbox", { name: "Select hello.txt" }).check();
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page
|
||||
.getByRole("button", { name: "Verify & download safe ZIP" })
|
||||
.click();
|
||||
expect((await downloadPromise).suggestedFilename()).toBe(
|
||||
"sample-safe-selection.zip",
|
||||
);
|
||||
expect(external).toEqual([]);
|
||||
});
|
||||
|
||||
test("blocks traversal and case-colliding ZIP paths", async ({ page }) => {
|
||||
await page.goto("/deep/nested/archive/");
|
||||
const archive = zipSync({
|
||||
"../escape.txt": strToU8("bad"),
|
||||
"Report.txt": strToU8("one"),
|
||||
"report.TXT": strToU8("two"),
|
||||
});
|
||||
await page.getByLabel("Choose archive").setInputFiles({
|
||||
name: "unsafe.zip",
|
||||
mimeType: "application/zip",
|
||||
buffer: Buffer.from(archive),
|
||||
});
|
||||
await expect(
|
||||
page.getByText("Parent-directory path segments are blocked."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/Path collides with entry/u)).toHaveCount(2);
|
||||
await expect(
|
||||
page.getByRole("checkbox", { name: "Select escape.txt" }),
|
||||
).toBeDisabled();
|
||||
await expect(
|
||||
page.getByRole("checkbox", { name: "Select Report.txt", exact: true }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test("creates a TAR and compares archive inventories", async ({ page }) => {
|
||||
await page.goto("/deep/nested/archive/");
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await page.getByLabel("Files").setInputFiles([
|
||||
{
|
||||
name: "alpha.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("alpha"),
|
||||
},
|
||||
{
|
||||
name: "beta.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("beta"),
|
||||
},
|
||||
]);
|
||||
await page.getByLabel("Output name").fill("browser-created");
|
||||
await page.getByLabel("Format").selectOption("tar");
|
||||
const createdPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Create & download" }).click();
|
||||
const created = await createdPromise;
|
||||
expect(created.suggestedFilename()).toBe("browser-created.tar");
|
||||
|
||||
await page.getByRole("button", { name: "Inspect & extract" }).click();
|
||||
await page.getByLabel("Choose archive").setInputFiles(await created.path());
|
||||
await expect(page.getByText("Inspected 2 entries locally.")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Compare" }).click();
|
||||
const left = zipSync({
|
||||
"same.txt": strToU8("same"),
|
||||
"changed.txt": strToU8("old"),
|
||||
});
|
||||
const right = zipSync({
|
||||
"same.txt": strToU8("same"),
|
||||
"changed.txt": strToU8("new"),
|
||||
});
|
||||
await page.getByLabel("Left archive").setInputFiles({
|
||||
name: "left.zip",
|
||||
mimeType: "application/zip",
|
||||
buffer: Buffer.from(left),
|
||||
});
|
||||
await page.getByLabel("Right archive").setInputFiles({
|
||||
name: "right.zip",
|
||||
mimeType: "application/zip",
|
||||
buffer: Buffer.from(right),
|
||||
});
|
||||
await page.getByRole("button", { name: "Compare locally" }).click();
|
||||
await expect(
|
||||
page.getByRole("row", { name: /changed\.txt changed/u }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("row", { name: /same\.txt/u })).toHaveCount(0);
|
||||
await page.getByRole("checkbox", { name: "Show changes only" }).uncheck();
|
||||
await expect(
|
||||
page.getByRole("row", { name: /same\.txt same/u }),
|
||||
).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { App } from "../../src/App";
|
||||
|
||||
describe("Archive Tools", () => {
|
||||
it("renders the local workbench and standard shell", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("Not found", { status: 404 })),
|
||||
);
|
||||
render(<App />);
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Archive Tools" }),
|
||||
).toBeVisible();
|
||||
expect(await screen.findByText("Browser-local")).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user