diff --git a/.changeset/loud-pandas-repeat.md b/.changeset/loud-pandas-repeat.md new file mode 100644 index 0000000..28dea61 --- /dev/null +++ b/.changeset/loud-pandas-repeat.md @@ -0,0 +1,9 @@ +--- +"@stll/stdnum": patch +--- + +Say which package is missing when the native binding cannot load. The platform +binaries are optional dependencies, so an installer that declines them leaves +the package importable and dead, throwing only on first use. The previous error +listed every supported target, including the one it was running on, which reads +as a portability problem rather than a missing install. diff --git a/packages/stdnum/src/__test__/native-node.test.ts b/packages/stdnum/src/__test__/native-node.test.ts new file mode 100644 index 0000000..146a0fd --- /dev/null +++ b/packages/stdnum/src/__test__/native-node.test.ts @@ -0,0 +1,110 @@ +/** + * The loader had no tests, which is how a real failure stayed invisible: the + * platform binaries are optionalDependencies, so an installer that declines + * them leaves this package importable and dead, throwing only on first use. + * The message that surfaced then said "supported targets: …, linux-x64-gnu, + * …" while failing ON linux-x64-gnu, which points the reader at a + * portability problem that does not exist. + * + * Every input the loader consults is injectable, so both branches are + * testable without touching the filesystem or the real platform. + */ + +import { describe, expect, test } from "bun:test"; + +import type { NativeStdnumBinding } from "../native"; +import { loadNativeStdnumBinding } from "../native-node"; + +/** Nothing resolves: the shape an install with no platform package has. */ +const requireNothing = (specifier: string): unknown => { + throw new Error(`Cannot find module '${specifier}'`); +}; + +describe("loadNativeStdnumBinding", () => { + test("names the uninstalled package when the platform IS a build target", () => { + expect(() => + loadNativeStdnumBinding({ + platform: "linux", + arch: "x64", + libc: "gnu", + env: {}, + requireModule: requireNothing, + }), + ).toThrow(/@stll\/stdnum-linux-x64-gnu/u); + }); + + test("says the target exists rather than blaming the platform", () => { + let message = ""; + try { + loadNativeStdnumBinding({ + platform: "darwin", + arch: "arm64", + env: {}, + requireModule: requireNothing, + }); + } catch (error) { + message = + error instanceof Error + ? error.message + : String(error); + } + + expect(message).toContain("IS a build target"); + expect(message).toContain("optionalDependency"); + // The old message listed every supported target and nothing else, which + // read as "your platform is unsupported" for a platform that is. + expect(message).not.toMatch( + /^Unable to load native stdnum binding.*supported targets/u, + ); + }); + + test("reports an unsupported platform as unsupported", () => { + let message = ""; + try { + loadNativeStdnumBinding({ + platform: "linux", + arch: "x64", + libc: "musl", + env: {}, + requireModule: requireNothing, + }); + } catch (error) { + message = + error instanceof Error + ? error.message + : String(error); + } + + expect(message).toContain( + "not among the build targets", + ); + expect(message).toContain("linux-x64-musl"); + }); + + test("prefers an explicit library path over the platform package", () => { + // Every member the binding contract requires; a partial object is + // rejected as "does not match the stdnum binding contract". + const binding = new Proxy( + {}, + { get: () => () => undefined }, + ) as NativeStdnumBinding; + const loaded = loadNativeStdnumBinding({ + platform: "linux", + arch: "x64", + libc: "gnu", + env: { + STELLA_STDNUM_NATIVE_LIBRARY_PATH: + "/tmp/custom.node", + }, + requireModule: (specifier) => { + if (specifier === "/tmp/custom.node") + return binding; + throw new Error( + `Cannot find module '${specifier}'`, + ); + }, + }); + + expect(loaded).toBe(binding); + }); +}); diff --git a/packages/stdnum/src/native-node.ts b/packages/stdnum/src/native-node.ts index 77792d8..122c4de 100644 --- a/packages/stdnum/src/native-node.ts +++ b/packages/stdnum/src/native-node.ts @@ -69,12 +69,31 @@ export const loadNativeStdnumBinding = ( ); } } + const current = [platform, arch, libc] + .filter(Boolean) + .join("-"); const supported = targets .map(([p, a, l]) => [p, a, l].filter(Boolean).join("-")) .join(", "); + + // Distinguish "we do not build for this platform" from "we do, but the + // package holding the binary is not installed". They read identically in a + // stack trace and have completely different fixes, and the second is the + // common one: the platform packages are optionalDependencies, so anything + // that declines to install them — `--no-optional`, an install-time version + // gate such as a release-age policy, a partial lockfile — leaves this + // package importable and non-functional, with no error until first use. + const reason = + match === undefined + ? `This platform is not among the build targets (${supported}).` + : `This platform IS a build target, so ${match[3]} exists but was not ` + + `installed. It is an optionalDependency: check that optional ` + + `dependencies are enabled, that ${match[3]} is not excluded by an ` + + `install policy, and that it appears in your lockfile.`; + throw new Error( - `Unable to load native stdnum binding for ${[platform, arch, libc].filter(Boolean).join("-")}; ` + - `supported targets: ${supported}.\n${errors.join("\n")}`, + `Unable to load the native stdnum binding for ${current}. ${reason}\n` + + `Tried:\n${errors.map((line) => ` ${line}`).join("\n")}`, ); };