| 1 | import assert from "node:assert/strict"; |
| 2 | import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; |
| 3 | import { tmpdir } from "node:os"; |
| 4 | import { dirname, join } from "node:path"; |
| 5 | import { test } from "node:test"; |
| 6 | import { fileURLToPath } from "node:url"; |
| 7 | import { spawnSync } from "node:child_process"; |
| 8 | import { deflateRawSync } from "node:zlib"; |
| 9 | import { |
| 10 | checkEntryModes, |
| 11 | checkMembers, |
| 12 | displayVersion, |
| 13 | inferArtifactKind, |
| 14 | listZipEntries, |
| 15 | readZipMember, |
| 16 | nsisProjectDefines, |
| 17 | numericVersion, |
| 18 | packagerOptions, |
| 19 | parseSigningFileList, |
| 20 | parseTarget, |
| 21 | parseVerboseListing, |
| 22 | PRODUCT, |
| 23 | readProductIdentity, |
| 24 | releaseVersions, |
| 25 | requiredMembers, |
| 26 | runBuildScript, |
| 27 | sanitizeShellPackageJson, |
| 28 | shellIgnore, |
| 29 | signingFileList, |
| 30 | versionTag, |
| 31 | validateMacServiceLink, |
| 32 | WINDOWS_FLAT_PAYLOAD, |
| 33 | } from "./lib.mjs"; |
| 34 | |
| 35 | const desktop = dirname(dirname(fileURLToPath(import.meta.url))); |
| 36 | const read = (path) => readFileSync(join(desktop, path), "utf8"); |
| 37 | const identity = { projectName: "reasonix-desktop", companyName: "Reasonix", productName: "Reasonix", copyright: "Copyright © 2026 Reasonix Contributors" }; |
| 38 | |
| 39 | test("build scripts preserve paths, arguments and environment without shell encoding", (t) => { |
| 40 | const directory = mkdtempSync(join(tmpdir(), "reasonix build & 中文 ")); |
| 41 | t.after(() => rmSync(directory, { recursive: true, force: true })); |
| 42 | mkdirSync(join(directory, "scripts")); |
| 43 | const output = join(directory, "result.json"); |
| 44 | writeFileSync(join(directory, "scripts", "fixture.mjs"), ` |
| 45 | import { writeFileSync } from "node:fs"; |
| 46 | writeFileSync(process.env.REASONIX_BUILD_TEST_OUTPUT, JSON.stringify({ |
| 47 | args: process.argv.slice(2), cwd: process.cwd(), channel: process.env.REASONIX_CHANNEL, |
| 48 | })); |
| 49 | `); |
| 50 | const args = ["", "a b", 'a"b', "C:\\build path\\", 'C:\\path\\"quoted"\\', "a&b|c<d>e^f%PATH%!x!", "$(echo unwanted)", "中文"]; |
| 51 | runBuildScript(directory, "fixture.mjs", args, { REASONIX_CHANNEL: "preview", REASONIX_BUILD_TEST_OUTPUT: output }); |
| 52 | const actual = JSON.parse(readFileSync(output, "utf8")); |
| 53 | assert.deepEqual(actual.args, args); |
| 54 | assert.equal(actual.channel, "preview"); |
| 55 | assert.equal(readFileSync(join(actual.cwd, "result.json"), "utf8"), readFileSync(output, "utf8")); |
| 56 | writeFileSync(join(directory, "scripts", "failure.mjs"), "process.exit(17);\n"); |
| 57 | assert.throws(() => runBuildScript(directory, "failure.mjs"), /failure\.mjs exited with 17/); |
| 58 | }); |
| 59 | |
| 60 | test("targets map Go platform names onto packager platform and arch", () => { |
| 61 | assert.deepEqual(parseTarget("darwin/universal"), { os: "darwin", arch: "universal", packagerPlatform: "darwin", packagerArch: "universal", spec: "darwin/universal", key: "darwin-universal" }); |
| 62 | assert.equal(parseTarget("windows/amd64").packagerArch, "x64"); |
| 63 | assert.equal(parseTarget("windows/arm64").packagerPlatform, "win32"); |
| 64 | assert.equal(parseTarget("linux/amd64").key, "linux-amd64"); |
| 65 | assert.throws(() => parseTarget("windows/universal"), /unsupported target/); |
| 66 | assert.throws(() => parseTarget("darwin"), /unsupported target/); |
| 67 | }); |
| 68 | |
| 69 | test("versions keep the full tag for identity and strip it for OS resources", () => { |
| 70 | assert.deepEqual(releaseVersions("v1.38.9-2"), { canonical: "v1.38.9-2", display: "1.38.9-2", resource: "1.38.9" }); |
| 71 | assert.deepEqual(releaseVersions("v1.2.3-preview.42"), { canonical: "v1.2.3-preview.42", display: "1.2.3-preview.42", resource: "1.2.3" }); |
| 72 | assert.equal(numericVersion("v1.2.3"), "1.2.3"); |
| 73 | assert.equal(numericVersion("v1.2.3-rc.1"), "1.2.3"); |
| 74 | assert.equal(displayVersion("v1.2.3-rc.1"), "1.2.3-rc.1"); |
| 75 | assert.equal(numericVersion("v0.0.0-local"), "0.0.0"); |
| 76 | assert.equal(versionTag("v1.20.0-preview.42"), "v1.20.0-preview.42"); |
| 77 | for (const bad of ["1.2.3", "v1.2", "v01.2.3", "v1.2.3+meta", ""]) assert.throws(() => numericVersion(bad), /version must look like/); |
| 78 | }); |
| 79 | |
| 80 | test("the product identity is a frozen constant and keeps the Wails-era bundle id", () => { |
| 81 | const product = readProductIdentity(); |
| 82 | assert.equal(product.productName, "Reasonix"); |
| 83 | assert.equal(product.projectName, "reasonix-desktop"); |
| 84 | assert.equal(product.companyName, "Reasonix"); |
| 85 | assert.match(product.copyright, /Reasonix Contributors/); |
| 86 | assert.equal(PRODUCT.bundleId, "com.wails.reasonix-desktop"); |
| 87 | }); |
| 88 | |
| 89 | test("only the shell bundle and its package.json enter the asar", () => { |
| 90 | for (const kept of ["", "/package.json", "/dist", "/dist/main.cjs", "/dist/preload.cjs", "/dist/desktopContract.json", "/dist/guestPreload.cjs"]) { |
| 91 | assert.equal(shellIgnore(kept), false, kept); |
| 92 | } |
| 93 | for (const dropped of ["/dist/main.cjs.map", "/src", "/src/main/index.ts", "/node_modules", "/node_modules/electron", "/scripts/build.mjs", "/tsconfig.json", "/README.md", "/artifacts"]) { |
| 94 | assert.equal(shellIgnore(dropped), true, dropped); |
| 95 | } |
| 96 | }); |
| 97 | |
| 98 | test("package.json uses the numeric native version; build.json owns the full release identity", () => { |
| 99 | const pkg = sanitizeShellPackageJson( |
| 100 | { name: "reasonix-desktop-shell", private: true, version: "0.0.0", type: "module", main: "dist/main.cjs", description: "shell", scripts: { build: "x" }, devDependencies: { electron: "44.2.0" }, engines: { node: ">=24" } }, |
| 101 | { version: "v1.2.3-rc.1", productName: "Reasonix" }, |
| 102 | ); |
| 103 | assert.deepEqual(pkg, { name: "reasonix-desktop-shell", description: "shell", main: "dist/main.cjs", type: "module", productName: "Reasonix", version: "1.2.3" }); |
| 104 | }); |
| 105 | |
| 106 | test("packager options pin the product identity and layout for every target", () => { |
| 107 | const common = { version: "v1.2.3-rc.1", identity, root: "/repo/desktop", electronVersion: "44.2.0", extraResources: ["/tmp/app", "/tmp/icons", "/tmp/build.json"] }; |
| 108 | const mac = packagerOptions({ ...common, target: parseTarget("darwin/universal"), icon: "/repo/desktop/build/darwin/icon.icns" }); |
| 109 | assert.equal(mac.dir, join("/repo/desktop", "electron")); |
| 110 | assert.equal(mac.name, "Reasonix"); |
| 111 | assert.equal(mac.executableName, "Reasonix"); |
| 112 | assert.equal(mac.platform, "darwin"); |
| 113 | assert.equal(mac.arch, "universal"); |
| 114 | assert.equal(mac.appBundleId, "com.wails.reasonix-desktop"); |
| 115 | assert.equal(mac.appVersion, "1.2.3"); |
| 116 | assert.equal(mac.buildVersion, "1.2.3"); |
| 117 | assert.equal(mac.appCopyright, identity.copyright); |
| 118 | assert.equal(mac.asar, true); |
| 119 | assert.equal(mac.prune, true); |
| 120 | assert.equal(mac.overwrite, true); |
| 121 | assert.equal(mac.icon, "/repo/desktop/build/darwin/icon.icns"); |
| 122 | assert.deepEqual(mac.extraResource, common.extraResources); |
| 123 | assert.equal(mac.ignore, shellIgnore); |
| 124 | assert.equal(mac.win32metadata, undefined); |
| 125 | |
| 126 | const win = packagerOptions({ ...common, target: parseTarget("windows/arm64"), icon: "/repo/desktop/build/windows/icon.ico" }); |
| 127 | assert.equal(win.platform, "win32"); |
| 128 | assert.equal(win.arch, "arm64"); |
| 129 | assert.deepEqual(win.win32metadata, { CompanyName: "Reasonix", FileDescription: "Reasonix", ProductName: "Reasonix", InternalName: "Reasonix", OriginalFilename: "Reasonix.exe" }); |
| 130 | |
| 131 | const linux = packagerOptions({ ...common, target: parseTarget("linux/amd64") }); |
| 132 | assert.equal(linux.platform, "linux"); |
| 133 | assert.equal(linux.arch, "x64"); |
| 134 | assert.equal("icon" in linux, false); |
| 135 | }); |
| 136 | |
| 137 | test("NSIS project defines replace the Wails-generated INFO_* values", () => { |
| 138 | const defines = nsisProjectDefines(identity, "v1.38.9-2"); |
| 139 | assert.ok(defines.startsWith(""), "UTF-8 BOM for makensis"); |
| 140 | assert.match(defines, /!define INFO_PROJECTNAME "reasonix-desktop"\r\n/); |
| 141 | assert.match(defines, /!define INFO_COMPANYNAME "Reasonix"\r\n/); |
| 142 | assert.match(defines, /!define INFO_PRODUCTNAME "Reasonix"\r\n/); |
| 143 | assert.match(defines, /!define INFO_PRODUCTVERSION "1\.38\.9"\r\n/); |
| 144 | assert.match(defines, /!define REASONIX_DISPLAY_VERSION "1\.38\.9-2"\r\n/); |
| 145 | assert.match(defines, /!define INFO_COPYRIGHT "Copyright © 2026 Reasonix Contributors"\r\n/); |
| 146 | assert.match(defines, /!define REASONIX_VERSION_TAG "v1\.38\.9-2"\r\n/); |
| 147 | }); |
| 148 | |
| 149 | test("signing files are every PE file, sorted, deduplicated and slash-normalised", () => { |
| 150 | const files = signingFileList([ |
| 151 | "reasonix-desktop.exe", |
| 152 | "app\\Reasonix.exe", |
| 153 | "app/ffmpeg.dll", |
| 154 | "app/resources/app.asar", |
| 155 | "app/LICENSE", |
| 156 | "app/vk_swiftshader_icd.json", |
| 157 | "app/d3dcompiler_47.DLL", |
| 158 | "./reasonix-uninstall.exe", |
| 159 | "reasonix-desktop.exe", |
| 160 | "reasonix-payload.json", |
| 161 | ]); |
| 162 | assert.deepEqual(files, ["app/Reasonix.exe", "app/d3dcompiler_47.DLL", "app/ffmpeg.dll", "reasonix-desktop.exe", "reasonix-uninstall.exe"]); |
| 163 | assert.deepEqual(parseSigningFileList("# comment\r\napp/Reasonix.exe\n\n reasonix-cli.exe \n"), ["app/Reasonix.exe", "reasonix-cli.exe"]); |
| 164 | assert.deepEqual([...WINDOWS_FLAT_PAYLOAD], ["reasonix-desktop.exe", "reasonix-guard.exe", "reasonix-launcher.exe", "reasonix-update-helper.exe", "reasonix-cli.exe", "reasonix-uninstall.exe"]); |
| 165 | }); |
| 166 | |
| 167 | test("required members cover every artifact and the checks report gaps", () => { |
| 168 | const macEntries = requiredMembers("darwin-zip").map(String); |
| 169 | assert.deepEqual(checkMembers([...macEntries, "Reasonix.app/", "Reasonix.app/Contents/"], "darwin-zip"), { missing: [], forbidden: [] }); |
| 170 | assert.deepEqual(checkMembers(macEntries.slice(1), "darwin-zip").missing, [macEntries[0]]); |
| 171 | assert.deepEqual(checkMembers([...macEntries, "Reasonix.app/Contents/MacOS/reasonix-guard"], "darwin-zip").forbidden, ["Reasonix.app/Contents/MacOS/reasonix-guard"]); |
| 172 | assert.deepEqual(checkMembers([...macEntries, "Reasonix.app/Contents/Resources/main.cjs.map"], "darwin-zip").forbidden, [String(/(^|\/)(?:[^/]+\.map|__tests__|testdata|\.cache|coverage|npm-debug\.log|pnpm-debug\.log|yarn-error\.log)(?:$|\/)/)]); |
| 173 | assert.ok(macEntries.includes("Reasonix.app/Contents/MacOS/reasonix-desktop")); |
| 174 | assert.ok(macEntries.includes("Reasonix.app/Contents/Resources/service/reasonix")); |
| 175 | assert.ok(macEntries.includes("Reasonix.app/Contents/Resources/service/reasonix-desktop")); |
| 176 | assert.deepEqual(checkMembers(requiredMembers("darwin-app-dir").map(String), "darwin-app-dir").missing, []); |
| 177 | |
| 178 | const portable = [ |
| 179 | "Reasonix.exe", "reasonix-cli.exe", "current.json", |
| 180 | "versions/v1.2.3-rc.1/reasonix-desktop.exe", "versions/v1.2.3-rc.1/reasonix-update-helper.exe", "versions/v1.2.3-rc.1/reasonix-cli.exe", |
| 181 | "versions/v1.2.3-rc.1/app/Reasonix.exe", "versions/v1.2.3-rc.1/app/resources/bin/reasonix-cli-launcher.exe", "versions/v1.2.3-rc.1/app/resources/app.asar", "versions/v1.2.3-rc.1/app/resources/app/index.html", "versions/v1.2.3-rc.1/app/resources/build.json", |
| 182 | ]; |
| 183 | assert.deepEqual(checkMembers(portable, "windows-portable-zip"), { missing: [], forbidden: [] }); |
| 184 | assert.deepEqual(checkMembers([...portable, "reasonix-launcher.exe"], "windows-portable-zip").forbidden, ["reasonix-launcher.exe"]); |
| 185 | assert.deepEqual(checkMembers([...portable, "reasonix-launcher.exe"], "windows-portable-zip", "legacy-dual"), { missing: [], forbidden: [] }); |
| 186 | assert.deepEqual(checkMembers(portable, "windows-portable-zip", "legacy-dual").missing, ["reasonix-launcher.exe"]); |
| 187 | assert.deepEqual(checkMembers([...portable, "unexpected.EXE"], "windows-portable-zip").forbidden, ["unexpected.EXE"]); |
| 188 | assert.throws(() => checkMembers(portable, "windows-portable-zip", "auto"), /unknown Windows portable layout/); |
| 189 | assert.deepEqual(checkMembers(portable.filter((name) => !name.endsWith("app/Reasonix.exe")), "windows-portable-zip").missing, [String(/^versions\/v[^/]+\/app\/Reasonix\.exe$/)]); |
| 190 | assert.deepEqual(checkMembers([...portable, "reasonix-guard.exe"], "windows-portable-zip").forbidden, ["reasonix-guard.exe"]); |
| 191 | |
| 192 | const winApp = ["Reasonix.exe", "ffmpeg.dll", "libEGL.dll", "libGLESv2.dll", "resources.pak", "icudtl.dat", "locales\\en-US.pak", "resources\\app.asar", "resources\\app\\index.html", "resources\\build.json", "resources\\icons\\appicon.png"]; |
| 193 | assert.deepEqual(checkMembers(winApp, "windows-app-dir"), { missing: [], forbidden: [] }); |
| 194 | |
| 195 | const tar = requiredMembers("linux-tar").map(String); |
| 196 | assert.deepEqual(checkMembers(tar, "linux-tar"), { missing: [], forbidden: [] }); |
| 197 | assert.ok(tar.includes("app/chrome-sandbox")); |
| 198 | const deb = requiredMembers("linux-deb").map((name) => `./${name}`); |
| 199 | assert.deepEqual(checkMembers(deb, "linux-deb"), { missing: [], forbidden: [] }); |
| 200 | assert.deepEqual(checkMembers([...deb, "./usr/bin/reasonix-guard"], "linux-deb").forbidden, ["usr/bin/reasonix-guard"]); |
| 201 | assert.deepEqual(checkMembers(requiredMembers("linux-app-dir").map(String), "linux-app-dir").missing, []); |
| 202 | assert.throws(() => checkMembers([], "nope"), /unknown artifact kind/); |
| 203 | }); |
| 204 | |
| 205 | test("macOS service compatibility link stays relative, internal and live", (t) => { |
| 206 | const root = mkdtempSync(join(tmpdir(), "reasonix-link-")); |
| 207 | t.after(() => rmSync(root, { recursive: true, force: true })); |
| 208 | const app = join(root, "Reasonix.app"); |
| 209 | const macOS = join(app, "Contents", "MacOS"); |
| 210 | const service = join(app, "Contents", "Resources", "service"); |
| 211 | mkdirSync(macOS, { recursive: true }); |
| 212 | mkdirSync(service, { recursive: true }); |
| 213 | writeFileSync(join(service, "reasonix-desktop"), "service"); |
| 214 | symlinkSync("../Resources/service/reasonix-desktop", join(macOS, "reasonix-desktop")); |
| 215 | assert.deepEqual(validateMacServiceLink(app), []); |
| 216 | |
| 217 | rmSync(join(macOS, "reasonix-desktop")); |
| 218 | symlinkSync("../../../../outside", join(macOS, "reasonix-desktop")); |
| 219 | assert.match(validateMacServiceLink(app).join("\n"), /does not resolve|dangling/); |
| 220 | }); |
| 221 | |
| 222 | test("artifact kinds are inferred from release names and bundle shapes", () => { |
| 223 | assert.equal(inferArtifactKind("/dist/Reasonix-darwin-arm64.zip", false), "darwin-zip"); |
| 224 | assert.equal(inferArtifactKind("/dist/Reasonix-windows-amd64.zip", false), "windows-portable-zip"); |
| 225 | assert.equal(inferArtifactKind("/dist/Reasonix-linux-amd64.tar.gz", false), "linux-tar"); |
| 226 | assert.equal(inferArtifactKind("/dist/Reasonix-linux-amd64.deb", false), "linux-deb"); |
| 227 | assert.equal(inferArtifactKind("/x/Reasonix.app", true, ["Contents"]), "darwin-app-dir"); |
| 228 | assert.equal(inferArtifactKind("/x/app", true, ["Reasonix.exe", "resources"]), "windows-app-dir"); |
| 229 | assert.equal(inferArtifactKind("/x/app", true, ["Reasonix", "chrome-sandbox"]), "linux-app-dir"); |
| 230 | assert.throws(() => inferArtifactKind("/dist/Reasonix-darwin-universal.dmg", false), /cannot infer/); |
| 231 | }); |
| 232 | |
| 233 | function storedZip(entries, compressed = false) { |
| 234 | const locals = []; |
| 235 | const centrals = []; |
| 236 | let offset = 0; |
| 237 | for (const [name, content] of entries) { |
| 238 | const nameBytes = Buffer.from(name, "utf8"); |
| 239 | const data = Buffer.from(content, "utf8"); |
| 240 | const packed = compressed ? deflateRawSync(data) : data; |
| 241 | const local = Buffer.alloc(30); |
| 242 | local.writeUInt32LE(0x04034b50, 0); |
| 243 | local.writeUInt16LE(compressed ? 8 : 0, 8); |
| 244 | local.writeUInt16LE(nameBytes.length, 26); |
| 245 | const central = Buffer.alloc(46); |
| 246 | central.writeUInt32LE(0x02014b50, 0); |
| 247 | central.writeUInt16LE(compressed ? 8 : 0, 10); |
| 248 | central.writeUInt32LE(packed.length, 20); |
| 249 | central.writeUInt32LE(data.length, 24); |
| 250 | central.writeUInt16LE(nameBytes.length, 28); |
| 251 | central.writeUInt32LE(offset, 42); |
| 252 | locals.push(local, nameBytes, packed); |
| 253 | centrals.push(central, nameBytes); |
| 254 | offset += local.length + nameBytes.length + packed.length; |
| 255 | } |
| 256 | const directory = Buffer.concat(centrals); |
| 257 | const end = Buffer.alloc(22); |
| 258 | end.writeUInt32LE(0x06054b50, 0); |
| 259 | end.writeUInt16LE(entries.length, 8); |
| 260 | end.writeUInt16LE(entries.length, 10); |
| 261 | end.writeUInt32LE(directory.length, 12); |
| 262 | end.writeUInt32LE(offset, 16); |
| 263 | return Buffer.concat([...locals, directory, end]); |
| 264 | } |
| 265 | |
| 266 | test("zip listing reads the central directory without extracting", () => { |
| 267 | const dir = mkdtempSync(join(tmpdir(), "reasonix-ziptest-")); |
| 268 | try { |
| 269 | const file = join(dir, "Reasonix-darwin-arm64.zip"); |
| 270 | writeFileSync(file, storedZip([["Reasonix.app/", ""], ["Reasonix.app/Contents/MacOS/Reasonix", "mach-o"], ["Reasonix.app/Contents/Resources/app/index.html", "<html>"]])); |
| 271 | assert.deepEqual(listZipEntries(file), ["Reasonix.app/", "Reasonix.app/Contents/MacOS/Reasonix", "Reasonix.app/Contents/Resources/app/index.html"]); |
| 272 | writeFileSync(join(dir, "not.zip"), "plain text"); |
| 273 | assert.throws(() => listZipEntries(join(dir, "not.zip")), /not a zip archive/); |
| 274 | } finally { |
| 275 | rmSync(dir, { recursive: true, force: true }); |
| 276 | } |
| 277 | }); |
| 278 | |
| 279 | test("Windows ZIP verification checks launcher bytes and explicitly selects historical layout", t => { |
| 280 | const dir = mkdtempSync(join(tmpdir(), "reasonix-entry-zip-")); |
| 281 | t.after(() => rmSync(dir, { recursive: true, force: true })); |
| 282 | const archive = join(dir, "Reasonix-windows-amd64.zip"); |
| 283 | const version = "versions/v1.38.9"; |
| 284 | const entries = [ |
| 285 | ["Reasonix.exe", "gui"], ["reasonix-cli.exe", "cli"], ["current.json", "{}"], |
| 286 | ...["reasonix-desktop.exe", "reasonix-update-helper.exe", "reasonix-cli.exe", "app/Reasonix.exe", "app/resources/bin/reasonix-cli-launcher.exe", "app/resources/app.asar", "app/resources/app/index.html", "app/resources/build.json"].map(name => [`${version}/${name}`, name]), |
| 287 | ]; |
| 288 | const verify = mode => spawnSync(process.execPath, [fileURLToPath(new URL("./verify.mjs", import.meta.url)), archive, "--portable-layout", mode], { encoding: "utf8" }); |
| 289 | for (const compressed of [false, true]) { |
| 290 | writeFileSync(archive, storedZip(entries, compressed)); |
| 291 | assert.equal(readZipMember(archive, "Reasonix.exe").toString(), "gui"); |
| 292 | assert.throws(() => readZipMember(archive, "missing"), /missing/); |
| 293 | assert.equal(verify("canonical").status, 0); |
| 294 | assert.notEqual(verify("legacy-dual").status, 0); |
| 295 | writeFileSync(archive, storedZip([...entries, ["reasonix-launcher.exe", "gui"]], compressed)); |
| 296 | assert.notEqual(verify("canonical").status, 0); |
| 297 | assert.equal(verify("legacy-dual").status, 0); |
| 298 | writeFileSync(archive, storedZip([...entries, ["reasonix-launcher.exe", "different"]], compressed)); |
| 299 | assert.notEqual(verify("legacy-dual").status, 0); |
| 300 | } |
| 301 | writeFileSync(archive, storedZip([...entries, ["Reasonix.exe", "duplicate"]])); |
| 302 | assert.throws(() => readZipMember(archive, "Reasonix.exe"), /duplicate/); |
| 303 | }); |
| 304 | |
| 305 | test("candidate layout declaration agrees with the portable verifier default", () => { |
| 306 | assert.equal(readFileSync(new URL("./windows-portable-layout.txt", import.meta.url), "utf8").trim(), "canonical"); |
| 307 | assert.ok(!requiredMembers("windows-portable-zip").includes("reasonix-launcher.exe")); |
| 308 | }); |
| 309 | |
| 310 | test("Linux listings reject a private app directory and unreadable files", () => { |
| 311 | const deb = parseVerboseListing([ |
| 312 | "drwxr-xr-x root/root 0 2026-09-05 10:00 ./", |
| 313 | "drwxr-xr-x root/root 0 2026-09-05 10:00 ./usr/lib/reasonix/app/", |
| 314 | "-rwxr-xr-x root/root 123456789 2026-09-05 10:00 ./usr/lib/reasonix/app/Reasonix", |
| 315 | "-rwsr-xr-x root/root 123456 2026-09-05 10:00 ./usr/lib/reasonix/app/chrome-sandbox", |
| 316 | "lrwxrwxrwx root/root 0 2026-09-05 10:00 ./usr/lib/reasonix/app/link -> Reasonix", |
| 317 | ]); |
| 318 | assert.deepEqual(deb.map((row) => row.name), ["./", "./usr/lib/reasonix/app/", "./usr/lib/reasonix/app/Reasonix", "./usr/lib/reasonix/app/chrome-sandbox", "./usr/lib/reasonix/app/link"]); |
| 319 | assert.deepEqual(checkEntryModes(deb, "linux-deb"), []); |
| 320 | assert.deepEqual(checkMembers(deb.map((row) => row.name), "linux-deb").forbidden, []); |
| 321 | |
| 322 | const privateApp = parseVerboseListing(["drwx------ root/root 0 2026-09-05 10:00 ./usr/lib/reasonix/app/"]); |
| 323 | assert.deepEqual(checkEntryModes(privateApp, "linux-deb"), ["./usr/lib/reasonix/app/ has mode drwx------; directories must be drwxr-xr-x"]); |
| 324 | const privateFile = parseVerboseListing(["-rw-r----- root/root 10 2026-09-05 10:00 ./usr/lib/reasonix/app/resources/app.asar"]); |
| 325 | assert.deepEqual(checkEntryModes(privateFile, "linux-deb"), ["./usr/lib/reasonix/app/resources/app.asar has mode -rw-r-----; files must be world-readable"]); |
| 326 | const foreignOwner = parseVerboseListing(["-rwxr-xr-x runner/docker 10 2026-09-05 10:00 ./usr/bin/reasonix-desktop"]); |
| 327 | assert.deepEqual(checkEntryModes(foreignOwner, "linux-deb"), ["./usr/bin/reasonix-desktop is owned by runner/docker; package members must be root/root"]); |
| 328 | |
| 329 | const tar = parseVerboseListing([ |
| 330 | "drwxr-xr-x runner/docker 0 2026-09-05 10:00:00 app/", |
| 331 | "-rwxr-xr-x runner/docker 42 2026-09-05 10:00:00 reasonix-desktop", |
| 332 | ]); |
| 333 | assert.deepEqual(checkEntryModes(tar, "linux-tar"), []); |
| 334 | assert.throws(() => parseVerboseListing(["drwxr-xr-x 0 runner docker 0 Sep 5 10:00 app/"]), /unrecognised listing line/); |
| 335 | }); |
| 336 | |
| 337 | test("the packaged app directory is made world-readable before Linux packaging", () => { |
| 338 | assert.match(read("packaging/package.mjs"), /chmodSync\(bundle, 0o755\)/); |
| 339 | }); |
| 340 | |
| 341 | test("the Linux package inputs install the Electron tree beside the update helper", () => { |
| 342 | const nfpm = read("build/linux/nfpm.yaml"); |
| 343 | assert.match(nfpm, /src: \.\/build\/bin\/app\n\s+dst: \/usr\/lib\/reasonix\/app\n\s+type: tree/); |
| 344 | assert.match(nfpm, /dst: \/usr\/lib\/reasonix\/reasonix-update-helper/); |
| 345 | assert.match(nfpm, /dst: \/usr\/share\/polkit-1\/actions\/io\.reasonix\.desktop\.update\.policy/); |
| 346 | assert.match(nfpm, /dst: \/usr\/bin\/reasonix-launcher/); |
| 347 | assert.doesNotMatch(nfpm, /dst: \/usr\/bin\/reasonix-guard/); |
| 348 | assert.match(nfpm, /postinstall: \.\/build\/linux\/postinstall\.sh/); |
| 349 | for (const dep of ["libgtk-3-0", "libnss3", "libgbm1", "libasound2", "pkexec"]) assert.ok(nfpm.includes(` - ${dep}`), dep); |
| 350 | const postinstall = read("build/linux/postinstall.sh"); |
| 351 | assert.match(postinstall, /chown root:root \/usr\/lib\/reasonix\/app\/chrome-sandbox/); |
| 352 | assert.match(postinstall, /chmod 4755 \/usr\/lib\/reasonix\/app\/chrome-sandbox/); |
| 353 | const entry = read("build/linux/reasonix.desktop"); |
| 354 | assert.match(entry, /^Exec=reasonix-launcher$/m); |
| 355 | assert.match(entry, /^Icon=reasonix-desktop$/m); |
| 356 | assert.match(entry, /^StartupWMClass=Reasonix$/m); |
| 357 | }); |
| 358 | |
| 359 | test("the NSIS script installs the Electron tree with both payload modes and no WebView2", () => { |
| 360 | const nsi = read("build/windows/installer/project.nsi"); |
| 361 | assert.ok(nsi.startsWith(""), "UTF-8 BOM"); |
| 362 | assert.match(nsi, /!include "reasonix_project\.nsh"/); |
| 363 | assert.doesNotMatch(nsi, /wails_tools\.nsh/); |
| 364 | assert.doesNotMatch(nsi, /webview2/i); |
| 365 | assert.equal((nsi.match(/!insertmacro reasonix\.files/g) ?? []).length, 2, "stage payload and normal install both extract the payload"); |
| 366 | assert.match(nsi, /File \/r "app"/); |
| 367 | assert.match(nsi, /ARG_REASONIX_AMD64_BINARY/); |
| 368 | assert.match(nsi, /ARG_REASONIX_ARM64_BINARY/); |
| 369 | assert.match(nsi, /!define UNINST_KEY_NAME "\$\{INFO_COMPANYNAME\}\$\{INFO_PRODUCTNAME\}"/); |
| 370 | assert.match(nsi, /!define PRODUCT_EXECUTABLE "\$\{INFO_PROJECTNAME\}\.exe"/); |
| 371 | assert.match(nsi, /RMDir \/r "\$INSTDIR\\versions"/); |
| 372 | assert.match(nsi, /File "\/oname=uninstall\.exe" "\$\{ARG_REASONIX_SIGNED_UNINSTALLER\}"/); |
| 373 | for (const releaseIdentity of [ |
| 374 | /\$INSTDIR\\versions\\\$\{REASONIX_VERSION_TAG\}/, |
| 375 | /\.installer-\$\{REASONIX_VERSION_TAG\}-\$R8/, |
| 376 | /--version "\$\{REASONIX_VERSION_TAG\}"/, |
| 377 | ]) assert.match(nsi, releaseIdentity); |
| 378 | for (const nativeIdentityLeak of [ |
| 379 | /\$INSTDIR\\versions\\v\$\{INFO_PRODUCTVERSION\}/, |
| 380 | /\.installer-v\$\{INFO_PRODUCTVERSION\}/, |
| 381 | /--version "v\$\{INFO_PRODUCTVERSION\}"/, |
| 382 | ]) assert.doesNotMatch(nsi, nativeIdentityLeak); |
| 383 | assert.deepEqual( |
| 384 | nsi.split(/\r?\n/).map(line => line.trim()).filter(line => line.includes("INFO_PRODUCTVERSION")), |
| 385 | [ |
| 386 | "## INFO_PRODUCTVERSION is numeric metadata only.", |
| 387 | 'VIProductVersion "${INFO_PRODUCTVERSION}.0"', |
| 388 | 'VIFileVersion "${INFO_PRODUCTVERSION}.0"', |
| 389 | ], |
| 390 | "numeric resource versions must never become install or runtime identity", |
| 391 | ); |
| 392 | const activation = nsi.slice(nsi.indexOf("Reasonix layout activator output:")); |
| 393 | const retry = activation.indexOf('MessageBox MB_ICONEXCLAMATION|MB_RETRYCANCEL "$(reasonixActivateLocked)" IDRETRY reasonix_layout_activate'); |
| 394 | assert.ok(retry > 0, "activation failure offers Retry against the kept staging directory"); |
| 395 | assert.ok(activation.indexOf('RMDir /r "$R9"') > retry, "staging is discarded only after the user gives up"); |
| 396 | }); |
| 397 | |
| 398 | test("the installer stamps the shortcuts it created without launching the desktop", () => { |
| 399 | const nsi = read("build/windows/installer/project.nsi"); |
| 400 | const maintenance = nsi.indexOf('--repair-shortcuts "$SMPROGRAMS\\${INFO_PRODUCTNAME}.lnk" "$DESKTOP\\${INFO_PRODUCTNAME}.lnk"'); |
| 401 | assert.ok(maintenance > nsi.indexOf('CreateShortCut "$DESKTOP\\${INFO_PRODUCTNAME}.lnk"'), "maintenance follows shortcut creation"); |
| 402 | assert.match(nsi.slice(maintenance, maintenance + 350), /Pop \$0/); |
| 403 | assert.match(nsi.slice(maintenance, maintenance + 350), /shortcut identity repair failed/); |
| 404 | }); |
| 405 | |
| 406 | test("installer unlock checks do not create or lock missing release entries", () => { |
| 407 | const nsi = read("build/windows/installer/project.nsi"); |
| 408 | const body = nsi.slice(nsi.indexOf("Function reasonix.waitForExecutableUnlock"), nsi.indexOf("FunctionEnd", nsi.indexOf("Function reasonix.waitForExecutableUnlock"))); |
| 409 | const opens = [...body.matchAll(/FileOpen \$1 "([^"]+)" a/g)]; |
| 410 | assert.equal(opens.length, 6); |
| 411 | for (const open of opens) { |
| 412 | const preceding = body.slice(0, open.index); |
| 413 | const guard = `IfFileExists "${open[1]}" 0 `; |
| 414 | const at = preceding.lastIndexOf(guard); |
| 415 | assert.ok(at >= 0, `missing existence guard for ${open[1]}`); |
| 416 | assert.match(preceding.slice(at), /^IfFileExists [^\n]+\r?\n\s+ClearErrors\s+$/); |
| 417 | } |
| 418 | }); |
| 419 |