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