| 1 | import { sha256 } from "@/Util/sha256"; |
| 2 | import { zipCode, zipFiles } from "@/Util/zip"; |
| 3 | import { strFromU8, unzipSync } from "fflate"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import { expect, test } from "alchemy-test"; |
| 6 | |
| 7 | test( |
| 8 | "zipCode is deterministic for identical inputs", |
| 9 | async () => { |
| 10 | const hash = () => |
| 11 | Effect.runPromise( |
| 12 | zipCode("export default 1", [ |
| 13 | { |
| 14 | path: "index.mjs.map", |
| 15 | content: JSON.stringify({ |
| 16 | version: 3, |
| 17 | sources: ["index.ts"], |
| 18 | }), |
| 19 | }, |
| 20 | ]).pipe(Effect.flatMap(sha256)), |
| 21 | ); |
| 22 | |
| 23 | const first = await hash(); |
| 24 | await new Promise((resolve) => setTimeout(resolve, 1100)); |
| 25 | expect(await hash()).toBe(first); |
| 26 | }, |
| 27 | { tags: ["unit", "local"] }, |
| 28 | ); |
| 29 | |
| 30 | // Nested paths must remain deterministic across builds. |
| 31 | test( |
| 32 | "zipCode is deterministic for nested package paths", |
| 33 | async () => { |
| 34 | const build = () => |
| 35 | Effect.runPromise( |
| 36 | zipCode("export default 1", [ |
| 37 | { |
| 38 | path: "node_modules/uuid/package.json", |
| 39 | content: JSON.stringify({ name: "uuid" }), |
| 40 | }, |
| 41 | ]), |
| 42 | ); |
| 43 | |
| 44 | const first = await build(); |
| 45 | const entries = unzipSync(first); |
| 46 | expect(strFromU8(entries["node_modules/uuid/package.json"]!)).toBe( |
| 47 | JSON.stringify({ name: "uuid" }), |
| 48 | ); |
| 49 | |
| 50 | await new Promise((resolve) => setTimeout(resolve, 1100)); |
| 51 | const second = await build(); |
| 52 | expect(await Effect.runPromise(sha256(second))).toBe( |
| 53 | await Effect.runPromise(sha256(first)), |
| 54 | ); |
| 55 | }, |
| 56 | { tags: ["unit", "local"] }, |
| 57 | ); |
| 58 | |
| 59 | test( |
| 60 | "zipFiles preserves binary data, Unicode, empty files, and input order independence", |
| 61 | async () => { |
| 62 | const files = [ |
| 63 | { path: "nested/你好.txt", content: "Hello 🌍" }, |
| 64 | { path: "binary", content: new Uint8Array([0, 255, 128, 1]) }, |
| 65 | { path: "empty", content: "" }, |
| 66 | { path: "large", content: "compress me".repeat(40000) }, |
| 67 | ]; |
| 68 | const first = await Effect.runPromise(zipFiles(files)); |
| 69 | const second = await Effect.runPromise(zipFiles([...files].reverse())); |
| 70 | expect(Buffer.isBuffer(first)).toBe(true); |
| 71 | expect(first.equals(second)).toBe(true); |
| 72 | const entries = unzipSync(first); |
| 73 | for (const file of files) { |
| 74 | expect(entries[file.path]).toEqual( |
| 75 | typeof file.content === "string" |
| 76 | ? new TextEncoder().encode(file.content) |
| 77 | : file.content, |
| 78 | ); |
| 79 | } |
| 80 | expect( |
| 81 | Object.keys(unzipSync(await Effect.runPromise(zipFiles([])))), |
| 82 | ).toEqual([]); |
| 83 | }, |
| 84 | { tags: ["unit", "local"] }, |
| 85 | ); |
| 86 | |
| 87 | test( |
| 88 | "zipFiles records fixed timestamps and Unix file types and permissions", |
| 89 | async () => { |
| 90 | const archive = await Effect.runPromise( |
| 91 | zipFiles([ |
| 92 | { path: "bin/tool", content: "#!/bin/sh", mode: 0o100755 }, |
| 93 | { path: "link", content: "bin/tool", mode: 0o120777 }, |
| 94 | { path: "plain", content: "text" }, |
| 95 | ]), |
| 96 | ); |
| 97 | // Read the ZIP central directory independently of the compression library. |
| 98 | const end = archive.length - 22; |
| 99 | expect(archive.readUInt32LE(end)).toBe(0x06054b50); |
| 100 | let offset = archive.readUInt32LE(end + 16); |
| 101 | const modes: Record<string, number> = {}; |
| 102 | for (let i = 0; i < archive.readUInt16LE(end + 10); i++) { |
| 103 | expect(archive.readUInt32LE(offset)).toBe(0x02014b50); |
| 104 | expect(archive[offset + 5]).toBe(3); |
| 105 | expect(archive.readUInt16LE(offset + 12)).toBe(0); |
| 106 | expect(archive.readUInt16LE(offset + 14)).toBe(33); |
| 107 | const nameLength = archive.readUInt16LE(offset + 28); |
| 108 | const name = archive.toString( |
| 109 | "utf8", |
| 110 | offset + 46, |
| 111 | offset + 46 + nameLength, |
| 112 | ); |
| 113 | modes[name] = archive.readUInt32LE(offset + 38) >>> 16; |
| 114 | offset += |
| 115 | 46 + |
| 116 | nameLength + |
| 117 | archive.readUInt16LE(offset + 30) + |
| 118 | archive.readUInt16LE(offset + 32); |
| 119 | } |
| 120 | expect(modes).toEqual({ |
| 121 | "bin/tool": 0o100755, |
| 122 | link: 0o120777, |
| 123 | plain: 0o100644, |
| 124 | }); |
| 125 | expect(strFromU8(unzipSync(archive).link!)).toBe("bin/tool"); |
| 126 | }, |
| 127 | { tags: ["unit", "local"] }, |
| 128 | ); |