|
| 1 | +// Unit tests for the tilion-fortress Node SDK. |
| 2 | +// |
| 3 | +// These cover the pure, release-critical logic that decides *which* bundle a user gets and |
| 4 | +// whether it is trusted — the platform resolver, the persona->flag mapping, the SHA256SUMS |
| 5 | +// parser, and the hasher — with no network and no browser launch. A regression here silently |
| 6 | +// ships the wrong binary or skips checksum verification, so it is worth gating in CI. |
| 7 | +// |
| 8 | +// Run: node --test sdk/node/test/ |
| 9 | +// |
| 10 | +// Mirrors sdk/python/tests/test_sdk.py — same shape, Node side. |
| 11 | +import { test } from "node:test"; |
| 12 | +import assert from "node:assert/strict"; |
| 13 | +import { mkdtempSync, writeFileSync } from "node:fs"; |
| 14 | +import { tmpdir } from "node:os"; |
| 15 | +import { join } from "node:path"; |
| 16 | +import { createHash } from "node:crypto"; |
| 17 | + |
| 18 | +import { resolvePlatform, personaArgs, sha256, expectedSha, ASSETS } from "../index.js"; |
| 19 | + |
| 20 | +// --- helpers --------------------------------------------------------------- |
| 21 | + |
| 22 | +// process.platform / process.arch are read at call time by resolvePlatform(), so we can |
| 23 | +// swap them for a case and restore afterwards. They are configurable data properties. |
| 24 | +function withProcess(platform, arch, fn) { |
| 25 | + const desc = { platform: Object.getOwnPropertyDescriptor(process, "platform"), |
| 26 | + arch: Object.getOwnPropertyDescriptor(process, "arch") }; |
| 27 | + Object.defineProperty(process, "platform", { value: platform, configurable: true }); |
| 28 | + Object.defineProperty(process, "arch", { value: arch, configurable: true }); |
| 29 | + try { return fn(); } |
| 30 | + finally { |
| 31 | + Object.defineProperty(process, "platform", desc.platform); |
| 32 | + Object.defineProperty(process, "arch", desc.arch); |
| 33 | + } |
| 34 | +} |
| 35 | + |
| 36 | +// Swap globalThis.fetch for one call and restore, so expectedSha() can be tested offline. |
| 37 | +async function withFetch(impl, fn) { |
| 38 | + const orig = globalThis.fetch; |
| 39 | + globalThis.fetch = impl; |
| 40 | + try { return await fn(); } |
| 41 | + finally { globalThis.fetch = orig; } |
| 42 | +} |
| 43 | + |
| 44 | +const okText = (body) => async () => ({ ok: true, text: async () => body }); |
| 45 | + |
| 46 | +// --- platform -------------------------------------------------------------- |
| 47 | + |
| 48 | +test("resolvePlatform maps supported platform/arch pairs", () => { |
| 49 | + const cases = [ |
| 50 | + ["linux", "x64", "linux-x64"], |
| 51 | + ["win32", "x64", "win-x64"], |
| 52 | + ["darwin", "arm64", "mac-arm64"], |
| 53 | + ["darwin", "x64", "mac-x64"], |
| 54 | + ]; |
| 55 | + for (const [platform, arch, expected] of cases) { |
| 56 | + assert.equal(withProcess(platform, arch, resolvePlatform), expected, `${platform}/${arch}`); |
| 57 | + } |
| 58 | +}); |
| 59 | + |
| 60 | +test("resolvePlatform returns null for unsupported combos", () => { |
| 61 | + const cases = [ |
| 62 | + ["linux", "arm64"], // no arm64 Linux bundle yet |
| 63 | + ["linux", "ia32"], |
| 64 | + ["win32", "arm64"], |
| 65 | + ["win32", "ia32"], |
| 66 | + ["freebsd", "x64"], |
| 67 | + ["android", "arm64"], |
| 68 | + ]; |
| 69 | + for (const [platform, arch] of cases) { |
| 70 | + assert.equal(withProcess(platform, arch, resolvePlatform), null, `${platform}/${arch}`); |
| 71 | + } |
| 72 | +}); |
| 73 | + |
| 74 | +// --- persona --------------------------------------------------------------- |
| 75 | + |
| 76 | +test("personaArgs returns [] for null / empty", () => { |
| 77 | + assert.deepEqual(personaArgs(null), []); |
| 78 | + assert.deepEqual(personaArgs(undefined), []); |
| 79 | + assert.deepEqual(personaArgs({}), []); |
| 80 | +}); |
| 81 | + |
| 82 | +test("personaArgs maps known keys to the right --uxr-* flags", () => { |
| 83 | + const args = personaArgs({ timezone: "America/New_York", hwConcurrency: 16, webglRenderer: "ANGLE" }); |
| 84 | + assert.ok(args.includes("--uxr-timezone=America/New_York")); |
| 85 | + assert.ok(args.includes("--uxr-hw-concurrency=16")); |
| 86 | + assert.ok(args.includes("--uxr-webgl-renderer=ANGLE")); |
| 87 | +}); |
| 88 | + |
| 89 | +test("personaArgs falls back to a --uxr- prefix for unknown keys (never a bare/branded flag)", () => { |
| 90 | + assert.deepEqual(personaArgs({ someNewSurface: "v" }), ["--uxr-someNewSurface=v"]); |
| 91 | +}); |
| 92 | + |
| 93 | +test("personaArgs output is always --uxr- prefixed", () => { |
| 94 | + const persona = { platform: "Win32", timezone: "UTC", webglRenderer: "ANGLE", |
| 95 | + deviceMemory: 8, screenWidth: 1920, canvasSeed: 42, weirdKey: "x" }; |
| 96 | + for (const a of personaArgs(persona)) assert.ok(a.startsWith("--uxr-"), a); |
| 97 | +}); |
| 98 | + |
| 99 | +// --- checksums ------------------------------------------------------------- |
| 100 | + |
| 101 | +test("sha256 matches Node crypto for a known buffer", async () => { |
| 102 | + const dir = mkdtempSync(join(tmpdir(), "fortress-sdk-")); |
| 103 | + const file = join(dir, "blob.bin"); |
| 104 | + const data = Buffer.from("fortress".repeat(4096)); |
| 105 | + writeFileSync(file, data); |
| 106 | + const expected = createHash("sha256").update(data).digest("hex"); |
| 107 | + assert.equal(await sha256(file), expected); |
| 108 | +}); |
| 109 | + |
| 110 | +test("expectedSha parses the matching asset from SHA256SUMS", async () => { |
| 111 | + const asset = ASSETS["linux-x64"].asset; |
| 112 | + const body = `aa11bb22 ${asset}\ndeadbeef tilion-fortress-win-x64.zip\n`; |
| 113 | + const got = await withFetch(okText(body), () => expectedSha(asset)); |
| 114 | + assert.equal(got, "aa11bb22"); |
| 115 | +}); |
| 116 | + |
| 117 | +test("expectedSha handles the sha256sum '*asset' binary marker", async () => { |
| 118 | + const asset = ASSETS["linux-x64"].asset; |
| 119 | + const got = await withFetch(okText(`CAFEF00D *${asset}\n`), () => expectedSha(asset)); |
| 120 | + assert.equal(got, "cafef00d"); // lower-cased |
| 121 | +}); |
| 122 | + |
| 123 | +test("expectedSha returns null when the asset is absent", async () => { |
| 124 | + const body = "aa11bb22 some-other-asset.tar.gz\n"; |
| 125 | + const got = await withFetch(okText(body), () => expectedSha(ASSETS["linux-x64"].asset)); |
| 126 | + assert.equal(got, null); |
| 127 | +}); |
| 128 | + |
| 129 | +test("expectedSha returns null on a non-ok response", async () => { |
| 130 | + const got = await withFetch(async () => ({ ok: false, status: 404 }), |
| 131 | + () => expectedSha(ASSETS["linux-x64"].asset)); |
| 132 | + assert.equal(got, null); |
| 133 | +}); |
| 134 | + |
| 135 | +test("expectedSha swallows a network error instead of throwing", async () => { |
| 136 | + const boom = async () => { throw new Error("network down"); }; |
| 137 | + const got = await withFetch(boom, () => expectedSha("anything")); |
| 138 | + assert.equal(got, null); |
| 139 | +}); |
| 140 | + |
| 141 | +// --- assets table ---------------------------------------------------------- |
| 142 | + |
| 143 | +test("ASSETS stays consistent with resolvePlatform", () => { |
| 144 | + // Every key resolvePlatform() can return must exist in ASSETS, and each launcher path must |
| 145 | + // live under tilion-fortress/ so extraction lands where ensureNative expects. |
| 146 | + const resolvable = ["linux-x64", "win-x64", "mac-arm64", "mac-x64"]; |
| 147 | + for (const key of resolvable) assert.ok(key in ASSETS, `missing asset for ${key}`); |
| 148 | + for (const [plat, { asset, kind, launcher }] of Object.entries(ASSETS)) { |
| 149 | + assert.ok(asset.startsWith("tilion-fortress-") && asset.includes(plat), `${plat}: ${asset}`); |
| 150 | + assert.ok(["tar", "zip"].includes(kind), `${plat}: kind ${kind}`); |
| 151 | + assert.ok(launcher.startsWith("tilion-fortress/"), `${plat}: launcher ${launcher}`); |
| 152 | + } |
| 153 | +}); |
0 commit comments