mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 15:12:48 +00:00
1092 lines
38 KiB
JavaScript
Vendored
1092 lines
38 KiB
JavaScript
Vendored
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import vm from 'node:vm';
|
|
import { transformSync } from '@babel/core';
|
|
import { afterAll, expect, test } from 'vitest';
|
|
import plugin, {
|
|
addonKey,
|
|
isHostPrebuiltNodeAddon,
|
|
resolveAddon,
|
|
} from './babel-plugin-node-addons.js';
|
|
|
|
const packageRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
const temporaryRoots = [];
|
|
|
|
afterAll(() => {
|
|
for (const root of temporaryRoots) {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
/** A scratch directory whose real path is stable for the test's duration. */
|
|
function scratch() {
|
|
const root = fs.mkdtempSync(path.join(packageRoot, '.node-addons-test-'));
|
|
temporaryRoots.push(root);
|
|
// `mkdtempSync` under /var on macOS is a symlink farm; resolve it once so
|
|
// expectations about the key match what the plugin sees.
|
|
return fs.realpathSync(root);
|
|
}
|
|
|
|
function write(filePath, contents = '') {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, contents);
|
|
}
|
|
|
|
function transform(source, { filename, platform = 'desktop', ...options }) {
|
|
return transformSync(source, {
|
|
filename,
|
|
caller: { name: 'metro', bundler: 'metro', platform },
|
|
configFile: false,
|
|
babelrc: false,
|
|
plugins: [[plugin, options]],
|
|
compact: false,
|
|
}).code;
|
|
}
|
|
|
|
/**
|
|
* A file that passes the host guard on the host the test runs on. The guard
|
|
* reads real bytes, so a valid header has to be written rather than a text
|
|
* placeholder.
|
|
*/
|
|
function hostAddonHeader() {
|
|
if (process.platform === 'linux') {
|
|
const header = Buffer.alloc(20);
|
|
Buffer.from([0x7f, 0x45, 0x4c, 0x46]).copy(header, 0);
|
|
header.writeUInt16LE(process.arch === 'arm64' ? 0xb7 : 0x3e, 18);
|
|
return header;
|
|
}
|
|
if (process.platform === 'darwin') {
|
|
// 64-bit thin little-endian Mach-O for the host CPU.
|
|
const header = Buffer.alloc(32);
|
|
Buffer.from([0xcf, 0xfa, 0xed, 0xfe]).copy(header, 0);
|
|
header.writeUInt32LE(process.arch === 'arm64' ? 0x0100000c : 0x01000007, 4);
|
|
return header;
|
|
}
|
|
if (process.platform === 'win32') {
|
|
// A PE image for the host machine: `MZ`, the `PE\0\0` signature at the
|
|
// offset named at 0x3C, and the machine field right after it.
|
|
return peHeader(process.arch === 'arm64' ? 0xaa64 : 0x8664);
|
|
}
|
|
return Buffer.from('not a real binary');
|
|
}
|
|
|
|
/** A PE image header for one machine value. */
|
|
function peHeader(machine) {
|
|
const header = Buffer.alloc(64);
|
|
Buffer.from([0x4d, 0x5a]).copy(header, 0);
|
|
header.writeUInt32LE(0x40, 0x3c);
|
|
// The signature sits at 0x40, which is past the 64 bytes read here, so the
|
|
// smallest valid file the plugin can accept is 0x46 bytes; write enough.
|
|
const full = Buffer.alloc(0x48);
|
|
header.copy(full, 0);
|
|
Buffer.from([0x50, 0x45, 0x00, 0x00]).copy(full, 0x40);
|
|
full.writeUInt16LE(machine, 0x44);
|
|
return full;
|
|
}
|
|
|
|
/** A file the host guard rejects: an ELF for the other architecture. */
|
|
function foreignAddonHeader() {
|
|
if (process.platform === 'darwin') {
|
|
const header = Buffer.alloc(32);
|
|
Buffer.from([0xcf, 0xfa, 0xed, 0xfe]).copy(header, 0);
|
|
header.writeUInt32LE(process.arch === 'arm64' ? 0x01000007 : 0x0100000c, 4);
|
|
return header;
|
|
}
|
|
if (process.platform === 'win32') {
|
|
// The other Windows machine: a PE for the CPU the host is not.
|
|
return peHeader(process.arch === 'arm64' ? 0x8664 : 0xaa64);
|
|
}
|
|
const header = Buffer.alloc(20);
|
|
Buffer.from([0x7f, 0x45, 0x4c, 0x46]).copy(header, 0);
|
|
header.writeUInt16LE(process.arch === 'arm64' ? 0x3e : 0xb7, 18);
|
|
return header;
|
|
}
|
|
|
|
function writeAddon(filePath, header = hostAddonHeader()) {
|
|
write(filePath, header);
|
|
}
|
|
|
|
/** An ELF header for one Linux e_machine value. */
|
|
function elfHeader(machine) {
|
|
const header = Buffer.alloc(20);
|
|
Buffer.from([0x7f, 0x45, 0x4c, 0x46]).copy(header, 0);
|
|
header.writeUInt16LE(machine, 18);
|
|
return header;
|
|
}
|
|
|
|
/** A 64-bit thin little-endian Mach-O header for one cputype value. */
|
|
function machoHeader(cputype) {
|
|
const header = Buffer.alloc(32);
|
|
Buffer.from([0xcf, 0xfa, 0xed, 0xfe]).copy(header, 0);
|
|
header.writeUInt32LE(cputype, 4);
|
|
return header;
|
|
}
|
|
|
|
/**
|
|
* Writes a binary for one named target, so a test can hand the plugin a host
|
|
* other than the one it runs on and still get bytes that match.
|
|
*/
|
|
/** `writeAddonFor` under a shorter name for the R4 tag tests. */
|
|
function writeAddonAt(filePath, hostPlatform, hostArch) {
|
|
writeAddonFor(filePath, hostPlatform, hostArch);
|
|
}
|
|
|
|
function writeAddonFor(filePath, hostPlatform, hostArch) {
|
|
if (hostPlatform === 'linux') {
|
|
writeAddon(filePath, elfHeader(hostArch === 'arm64' ? 0xb7 : 0x3e));
|
|
return;
|
|
}
|
|
if (hostPlatform === 'darwin') {
|
|
writeAddon(
|
|
filePath,
|
|
machoHeader(hostArch === 'arm64' ? 0x0100000c : 0x01000007),
|
|
);
|
|
return;
|
|
}
|
|
if (hostPlatform === 'win32') {
|
|
writeAddon(filePath, peHeader(hostArch === 'arm64' ? 0xaa64 : 0x8664));
|
|
return;
|
|
}
|
|
// Other platforms' binaries are not header-checked.
|
|
writeAddon(filePath);
|
|
}
|
|
|
|
/**
|
|
* The `_requireNodeLibrary(...)` arguments in transformed code. The plugin emits
|
|
* paths through `t.stringLiteral`, so on Windows every backslash is escaped
|
|
* (`"C:\\dir\\addon.node"`); each captured literal is decoded back to the real
|
|
* string before it is compared.
|
|
*/
|
|
function callsIn(code) {
|
|
const calls = [];
|
|
const pattern =
|
|
/_requireNodeLibrary\(\s*"((?:[^"\\]|\\.)*)"(?:\s*,\s*"((?:[^"\\]|\\.)*)")?\s*\)/gu;
|
|
const decode = value =>
|
|
value === undefined ? undefined : JSON.parse(`"${value}"`);
|
|
for (const match of code.matchAll(pattern)) {
|
|
calls.push({ key: decode(match[1]), path: decode(match[2]) });
|
|
}
|
|
return calls;
|
|
}
|
|
|
|
test('rewrites a relative .node require on gpui', () => {
|
|
const root = scratch();
|
|
const addon = path.join(root, 'build/Release/addon.node');
|
|
writeAddon(addon);
|
|
const code = transform(
|
|
"const addon = require('./build/Release/addon.node');",
|
|
{
|
|
filename: path.join(root, 'index.js'),
|
|
},
|
|
);
|
|
expect(code).toContain('_requireNodeLibrary');
|
|
expect(code).not.toContain('require(');
|
|
const [call] = callsIn(code);
|
|
expect(call.key).toBe(`local/${addonKey(addon).split('/')[1]}/addon.node`);
|
|
expect(call.path).toBe(addon);
|
|
});
|
|
|
|
test('leaves the require untouched on ios and android', () => {
|
|
const root = scratch();
|
|
writeAddon(path.join(root, 'addon.node'));
|
|
const source = "const addon = require('./addon.node');";
|
|
for (const platform of ['ios', 'android', 'web']) {
|
|
const code = transform(source, {
|
|
filename: path.join(root, 'index.js'),
|
|
platform,
|
|
});
|
|
expect(code).toContain("require('./addon.node')");
|
|
expect(code).not.toContain('_requireNodeLibrary');
|
|
}
|
|
});
|
|
|
|
test('rewrites every import form', () => {
|
|
const root = scratch();
|
|
writeAddon(path.join(root, 'thing.node'));
|
|
const filename = path.join(root, 'index.js');
|
|
const cases = [
|
|
[
|
|
"import binding from './thing.node';",
|
|
/const _nodeAddonExports = _requireNodeLibrary\(/u,
|
|
],
|
|
[
|
|
"import * as binding from './thing.node';",
|
|
/const binding = _nodeAddonExports/u,
|
|
],
|
|
[
|
|
"import { add, sub as minus } from './thing.node';",
|
|
/const \{\s+add: add,\s+sub: minus\s+\} = _nodeAddonExports/u,
|
|
],
|
|
["import './thing.node';", /^_requireNodeLibrary\(/mu],
|
|
];
|
|
for (const [source, pattern] of cases) {
|
|
const code = transform(source, { filename });
|
|
expect(code, source).toMatch(pattern);
|
|
expect(code, source).toContain('_requireNodeLibrary(');
|
|
}
|
|
});
|
|
|
|
test('a bare .node specifier resolves through node resolution', () => {
|
|
const root = scratch();
|
|
const addon = path.join(
|
|
root,
|
|
'node_modules/some-package/prebuilds/thing.node',
|
|
);
|
|
write(filePathIn(root, 'node_modules/some-package/package.json'), '{}');
|
|
writeAddon(addon);
|
|
const code = transform("require('some-package/prebuilds/thing.node');", {
|
|
filename: path.join(root, 'index.js'),
|
|
});
|
|
const [call] = callsIn(code);
|
|
expect(call.path).toBe(addon);
|
|
expect(call.key).toBe('node_modules/some-package/prebuilds/thing.node');
|
|
});
|
|
|
|
test('a missing .node file becomes a throwing expression with a code', () => {
|
|
const root = scratch();
|
|
const code = transform("require('./missing.node');", {
|
|
filename: path.join(root, 'index.js'),
|
|
});
|
|
expect(code).toContain("Cannot find module './missing.node'");
|
|
expect(code).toContain('MODULE_NOT_FOUND');
|
|
expect(code).not.toContain('_requireNodeLibrary');
|
|
});
|
|
|
|
test('a surrounding try/catch catches the throwing expression', () => {
|
|
const root = scratch();
|
|
const code = transform(
|
|
"let value; try { value = require('./missing.node'); } catch (error) { value = error.code; }",
|
|
{ filename: path.join(root, 'index.js') },
|
|
);
|
|
// Evaluated in a bare VM context so the assertion is about the emitted
|
|
// expression, not about this test file's scope.
|
|
const result = vm.runInNewContext(`${code}\nvalue;`, {});
|
|
expect(result).toBe('MODULE_NOT_FOUND');
|
|
});
|
|
|
|
test('R2 fires only when nothing else would resolve', () => {
|
|
const root = scratch();
|
|
const filename = path.join(root, 'index.js');
|
|
writeAddon(path.join(root, 'only.node'));
|
|
expect(transform("require('./only');", { filename })).toContain(
|
|
'_requireNodeLibrary',
|
|
);
|
|
|
|
// With a real .js sibling the require must be left alone.
|
|
write(path.join(root, 'both.js'), 'module.exports = {};');
|
|
writeAddon(path.join(root, 'both.node'));
|
|
expect(transform("require('./both');", { filename })).toContain(
|
|
"require('./both')",
|
|
);
|
|
|
|
// With a directory of that name, likewise.
|
|
write(path.join(root, 'dir/index.js'), 'module.exports = {};');
|
|
writeAddon(path.join(root, 'dir.node'));
|
|
expect(transform("require('./dir');", { filename })).toContain(
|
|
"require('./dir')",
|
|
);
|
|
|
|
// With a package.json in the directory, likewise.
|
|
write(path.join(root, 'pkg/package.json'), '{"main":"x.js"}');
|
|
writeAddon(path.join(root, 'pkg.node'));
|
|
expect(transform("require('./pkg');", { filename })).toContain(
|
|
"require('./pkg')",
|
|
);
|
|
});
|
|
|
|
test('R3 rewrites a bare package whose main is an addon', () => {
|
|
const root = scratch();
|
|
const addon = path.join(root, 'node_modules/native-thing/index.node');
|
|
write(
|
|
path.join(root, 'node_modules/native-thing/package.json'),
|
|
JSON.stringify({ name: 'native-thing', main: 'index.node' }),
|
|
);
|
|
writeAddon(addon);
|
|
const code = transform("require('native-thing');", {
|
|
filename: path.join(root, 'index.js'),
|
|
});
|
|
const [call] = callsIn(code);
|
|
expect(call.key).toBe('node_modules/native-thing/index.node');
|
|
expect(call.path).toBe(addon);
|
|
});
|
|
|
|
test('a bare package whose main is JavaScript is left alone', () => {
|
|
const root = scratch();
|
|
write(
|
|
path.join(root, 'node_modules/plain-thing/package.json'),
|
|
JSON.stringify({ name: 'plain-thing', main: 'index.js' }),
|
|
);
|
|
write(path.join(root, 'node_modules/plain-thing/index.js'), '');
|
|
expect(
|
|
transform("require('plain-thing');", {
|
|
filename: path.join(root, 'index.js'),
|
|
}),
|
|
).toContain("require('plain-thing')");
|
|
});
|
|
|
|
test('key: everything from the first node_modules segment', () => {
|
|
const root = scratch();
|
|
const addon = path.join(
|
|
root,
|
|
'node_modules/a/node_modules/b/prebuilds/x.node',
|
|
);
|
|
writeAddon(addon);
|
|
expect(addonKey(addon)).toBe(
|
|
'node_modules/a/node_modules/b/prebuilds/x.node',
|
|
);
|
|
});
|
|
|
|
test('key: the local form for a path outside node_modules', () => {
|
|
const root = scratch();
|
|
const addon = path.join(root, 'build/Release/addon.node');
|
|
writeAddon(addon);
|
|
const key = addonKey(addon);
|
|
expect(key).toMatch(/^local\/[0-9a-f]{8}\/addon\.node$/u);
|
|
expect(key).not.toContain('..');
|
|
expect(key.startsWith('/')).toBe(false);
|
|
});
|
|
|
|
test('key: a symlinked package directory uses the real path', () => {
|
|
const root = scratch();
|
|
const real = path.join(root, 'real-package/prebuilds/x.node');
|
|
write(filePathIn(root, 'real-package/package.json'), '{}');
|
|
writeAddon(real);
|
|
const link = path.join(root, 'node_modules/linked-package');
|
|
fs.mkdirSync(path.dirname(link), { recursive: true });
|
|
// A symbolic link needs an elevated process or Developer Mode on Windows; a
|
|
// directory junction needs neither and `fs.realpath` resolves it to the same
|
|
// target, which is the behaviour under test.
|
|
fs.symlinkSync(
|
|
path.dirname(path.dirname(real)),
|
|
link,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
const code = transform("require('linked-package/prebuilds/x.node');", {
|
|
filename: path.join(root, 'index.js'),
|
|
});
|
|
const [call] = callsIn(code);
|
|
// The key comes from the real path (section 3.3), and a symlinked workspace
|
|
// package resolves outside node_modules, so it takes the `local/` form —
|
|
// the same key the real path produces when required directly, which is the
|
|
// invariant that matters: one file, one key, whichever way it is reached.
|
|
expect(call.key).toBe(addonKey(real));
|
|
expect(call.key).toMatch(/^local\/[0-9a-f]{8}\/x\.node$/u);
|
|
});
|
|
|
|
test('embedPaths: false omits the second argument', () => {
|
|
const root = scratch();
|
|
writeAddon(path.join(root, 'addon.node'));
|
|
const code = transform("require('./addon.node');", {
|
|
filename: path.join(root, 'index.js'),
|
|
embedPaths: false,
|
|
});
|
|
expect(code).toMatch(/_requireNodeLibrary\("[^"]+"\)/u);
|
|
expect(callsIn(code)[0].path).toBeUndefined();
|
|
});
|
|
|
|
test('a binary for another platform is treated as missing', () => {
|
|
const root = scratch();
|
|
const addon = path.join(root, 'foreign.node');
|
|
write(addon, foreignAddonHeader());
|
|
expect(isHostPrebuiltNodeAddon(addon, process.platform, process.arch)).toBe(
|
|
false,
|
|
);
|
|
const code = transform("require('./foreign.node');", {
|
|
filename: path.join(root, 'index.js'),
|
|
});
|
|
expect(code).toContain('MODULE_NOT_FOUND');
|
|
expect(code).not.toContain('_requireNodeLibrary');
|
|
});
|
|
|
|
test('a path segment naming another platform is rejected without reading', () => {
|
|
const root = scratch();
|
|
// A segment naming a platform that is not the host's, so the guard rejects it
|
|
// before it ever opens the file (the bytes written are the host's, which is
|
|
// what makes this a test of the segment check rather than the header check).
|
|
const foreign = process.platform === 'win32' ? 'linux' : 'win32';
|
|
const foreignArch = process.arch === 'arm64' ? 'arm64' : 'x64';
|
|
const addon = path.join(
|
|
root,
|
|
`prebuilds/${foreign}-${foreignArch}/thing.node`,
|
|
);
|
|
writeAddon(addon);
|
|
expect(isHostPrebuiltNodeAddon(addon, process.platform, process.arch)).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('the host platform and arch are options, so tests run anywhere', () => {
|
|
const root = scratch();
|
|
const linuxX64 = Buffer.alloc(20);
|
|
Buffer.from([0x7f, 0x45, 0x4c, 0x46]).copy(linuxX64, 0);
|
|
linuxX64.writeUInt16LE(0x3e, 18);
|
|
const addon = path.join(root, 'linux.node');
|
|
write(addon, linuxX64);
|
|
|
|
expect(isHostPrebuiltNodeAddon(addon, 'linux', 'x64')).toBe(true);
|
|
expect(isHostPrebuiltNodeAddon(addon, 'linux', 'arm64')).toBe(false);
|
|
|
|
const code = transform("require('./linux.node');", {
|
|
filename: path.join(root, 'index.js'),
|
|
hostPlatform: 'linux',
|
|
hostArch: 'x64',
|
|
});
|
|
expect(code).toContain('_requireNodeLibrary');
|
|
|
|
const foreign = transform("require('./linux.node');", {
|
|
filename: path.join(root, 'index.js'),
|
|
hostPlatform: 'linux',
|
|
hostArch: 'arm64',
|
|
});
|
|
expect(foreign).toContain('MODULE_NOT_FOUND');
|
|
});
|
|
|
|
test('a universal Mach-O with a host slice is accepted', () => {
|
|
const root = scratch();
|
|
const fat = Buffer.alloc(8 + 2 * 20);
|
|
Buffer.from([0xca, 0xfe, 0xba, 0xbe]).copy(fat, 0);
|
|
fat.writeUInt32BE(2, 4);
|
|
fat.writeUInt32BE(0x01000007, 8); // x86_64
|
|
fat.writeUInt32BE(0x0100000c, 8 + 20); // arm64
|
|
const addon = path.join(root, 'universal.node');
|
|
write(addon, fat);
|
|
expect(isHostPrebuiltNodeAddon(addon, 'darwin', 'arm64')).toBe(true);
|
|
expect(isHostPrebuiltNodeAddon(addon, 'darwin', 'x64')).toBe(true);
|
|
});
|
|
|
|
test('a universal Mach-O without a host slice is rejected', () => {
|
|
const root = scratch();
|
|
const fat = Buffer.alloc(8 + 20);
|
|
Buffer.from([0xca, 0xfe, 0xba, 0xbe]).copy(fat, 0);
|
|
fat.writeUInt32BE(1, 4);
|
|
fat.writeUInt32BE(0x01000007, 8); // x86_64 only
|
|
const addon = path.join(root, 'intel-only.node');
|
|
write(addon, fat);
|
|
expect(isHostPrebuiltNodeAddon(addon, 'darwin', 'arm64')).toBe(false);
|
|
expect(isHostPrebuiltNodeAddon(addon, 'darwin', 'x64')).toBe(true);
|
|
});
|
|
|
|
test('resolveAddon reports a non-addon specifier as null', () => {
|
|
const root = scratch();
|
|
write(path.join(root, 'plain.js'), '');
|
|
expect(
|
|
resolveAddon('./plain', root, {
|
|
hostPlatform: process.platform,
|
|
hostArch: process.arch,
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
resolveAddon('react', root, {
|
|
hostPlatform: process.platform,
|
|
hostArch: process.arch,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
test('a dynamic import and a computed require are left alone', () => {
|
|
const root = scratch();
|
|
writeAddon(path.join(root, 'addon.node'));
|
|
const filename = path.join(root, 'index.js');
|
|
expect(transform("import('./addon.node');", { filename })).toContain(
|
|
"import('./addon.node')",
|
|
);
|
|
expect(transform('require(name);', { filename })).toContain('require(name)');
|
|
expect(transform('require(`./addon.node`);', { filename })).toContain(
|
|
'require(`./addon.node`)',
|
|
);
|
|
});
|
|
|
|
test('the key never contains a backslash or a parent segment', () => {
|
|
const root = scratch();
|
|
const addon = path.join(root, 'build/Release/addon.node');
|
|
writeAddon(addon);
|
|
const key = addonKey(addon);
|
|
expect(key).not.toContain('\\');
|
|
expect(key.split('/')).not.toContain('..');
|
|
});
|
|
|
|
function filePathIn(root, relative) {
|
|
return path.join(root, relative);
|
|
}
|
|
|
|
// --- R4/R5/R6: packages that pick their binary at run time ------------------
|
|
|
|
/**
|
|
* Writes a `prebuilds/<tag>/<name>.node` tree plus a package.json, the layout
|
|
* `node-gyp-build` uses.
|
|
*/
|
|
function writeNodeGypBuildPackage(root, tags) {
|
|
for (const tag of tags) {
|
|
writeAddon(path.join(root, 'prebuilds', tag, 'thing.node'));
|
|
}
|
|
write(
|
|
path.join(root, 'package.json'),
|
|
JSON.stringify({ name: 'gyp-thing', version: '1.0.0', main: 'index.js' }),
|
|
);
|
|
}
|
|
|
|
/** The host's `node-gyp-build` tag, matching the plugin's own choice. */
|
|
function hostPrebuildTag() {
|
|
return `${process.platform}-${process.arch}`;
|
|
}
|
|
|
|
test('R4 resolves require("node-gyp-build")(__dirname)', () => {
|
|
const root = scratch();
|
|
writeNodeGypBuildPackage(root, [hostPrebuildTag()]);
|
|
const code = transform(
|
|
"module.exports = require('node-gyp-build')(__dirname);",
|
|
{ filename: path.join(root, 'index.js') },
|
|
);
|
|
const [call] = callsIn(code);
|
|
expect(call.path).toBe(
|
|
path.join(root, 'prebuilds', hostPrebuildTag(), 'thing.node'),
|
|
);
|
|
expect(code).not.toContain('node-gyp-build');
|
|
});
|
|
|
|
test('R4 resolves path.join(__dirname, "sub")', () => {
|
|
const root = scratch();
|
|
const fixture = path.join(root, 'native');
|
|
writeNodeGypBuildPackage(fixture, [hostPrebuildTag()]);
|
|
const code = transform(
|
|
"module.exports = require('node-gyp-build')(require('path').join(__dirname, 'native'));",
|
|
{ filename: path.join(root, 'index.js') },
|
|
);
|
|
expect(callsIn(code)[0].path).toBe(
|
|
path.join(fixture, 'prebuilds', hostPrebuildTag(), 'thing.node'),
|
|
);
|
|
});
|
|
|
|
test('R4 prefers the host tag over node.napi', () => {
|
|
const root = scratch();
|
|
writeNodeGypBuildPackage(root, [hostPrebuildTag(), 'node.napi']);
|
|
const code = transform("require('node-gyp-build')(__dirname);", {
|
|
filename: path.join(root, 'index.js'),
|
|
});
|
|
expect(callsIn(code)[0].path).toBe(
|
|
path.join(root, 'prebuilds', hostPrebuildTag(), 'thing.node'),
|
|
);
|
|
});
|
|
|
|
test('R4 falls back to an in-tree build/Release binary', () => {
|
|
const root = scratch();
|
|
writeAddon(path.join(root, 'build/Release/built.node'));
|
|
write(
|
|
path.join(root, 'package.json'),
|
|
JSON.stringify({ name: 'gyp-built', version: '1.0.0' }),
|
|
);
|
|
const code = transform("require('node-gyp-build')(__dirname);", {
|
|
filename: path.join(root, 'index.js'),
|
|
});
|
|
expect(callsIn(code)[0].path).toBe(
|
|
path.join(root, 'build/Release/built.node'),
|
|
);
|
|
});
|
|
|
|
test('R4 emits a throwing expression and warns when nothing resolves', () => {
|
|
const root = scratch();
|
|
write(
|
|
path.join(root, 'package.json'),
|
|
JSON.stringify({ name: 'gyp-empty', version: '1.0.0' }),
|
|
);
|
|
const code = transform("require('node-gyp-build')(__dirname);", {
|
|
filename: path.join(root, 'index.js'),
|
|
});
|
|
expect(code).toContain('MODULE_NOT_FOUND');
|
|
expect(code).not.toContain('_requireNodeLibrary');
|
|
});
|
|
|
|
test('R4 ignores a dynamic directory argument', () => {
|
|
const root = scratch();
|
|
const code = transform("require('node-gyp-build')(someDir);", {
|
|
filename: path.join(root, 'index.js'),
|
|
});
|
|
expect(code).toContain("require('node-gyp-build')(someDir)");
|
|
});
|
|
|
|
test('R5 resolves require("bindings")("thing")', () => {
|
|
const root = scratch();
|
|
const addon = path.join(root, 'build/Release/thing.node');
|
|
writeAddon(addon);
|
|
write(
|
|
path.join(root, 'package.json'),
|
|
JSON.stringify({ name: 'binding-thing', version: '1.0.0' }),
|
|
);
|
|
const code = transform("module.exports = require('bindings')('thing');", {
|
|
filename: path.join(root, 'index.js'),
|
|
});
|
|
expect(callsIn(code)[0].path).toBe(addon);
|
|
expect(code).not.toContain("require('bindings')");
|
|
});
|
|
|
|
test('R5 accepts the { bindings: name } form', () => {
|
|
const root = scratch();
|
|
const addon = path.join(root, 'build/Release/thing.node');
|
|
writeAddon(addon);
|
|
const code = transform(
|
|
"module.exports = require('bindings')({ bindings: 'thing' });",
|
|
{ filename: path.join(root, 'lib/index.js') },
|
|
);
|
|
expect(callsIn(code)[0].path).toBe(addon);
|
|
});
|
|
|
|
test('R5 searches upward for the package root, like bindings does', () => {
|
|
const root = scratch();
|
|
const addon = path.join(root, 'build/Release/thing.node');
|
|
writeAddon(addon);
|
|
write(
|
|
path.join(root, 'package.json'),
|
|
JSON.stringify({ name: 'binding-thing', version: '1.0.0' }),
|
|
);
|
|
const code = transform("require('bindings')('thing');", {
|
|
filename: path.join(root, 'lib/deep/index.js'),
|
|
});
|
|
expect(callsIn(code)[0].path).toBe(addon);
|
|
});
|
|
|
|
test('R5 emits a throwing expression when nothing resolves', () => {
|
|
const root = scratch();
|
|
write(
|
|
path.join(root, 'package.json'),
|
|
JSON.stringify({ name: 'binding-empty', version: '1.0.0' }),
|
|
);
|
|
const code = transform("require('bindings')('missing');", {
|
|
filename: path.join(root, 'index.js'),
|
|
});
|
|
expect(code).toContain('MODULE_NOT_FOUND');
|
|
});
|
|
|
|
/** The napi-rs triple the plugin should choose for the host. */
|
|
function hostNapiRsTriple() {
|
|
if (process.platform === 'linux') return `linux-${process.arch}-gnu`;
|
|
if (process.platform === 'win32') return `win32-${process.arch}-msvc`;
|
|
return `${process.platform}-${process.arch}`;
|
|
}
|
|
|
|
test('R6 replaces a napi-rs generated loader with one require', () => {
|
|
const root = scratch();
|
|
const triple = hostNapiRsTriple();
|
|
// Three distinct platforms, none of which is the host's, so only the host
|
|
// branch can resolve. The names must be ones the napi-rs triple scanner
|
|
// recognises, otherwise this reads as a hand-written two-platform file and R6
|
|
// never fires.
|
|
const otherTriple = 'freebsd-x64';
|
|
const thirdTriple = 'android-x64';
|
|
writeAddon(path.join(root, `thing.${triple}.node`));
|
|
writeAddon(path.join(root, `thing.${otherTriple}.node`));
|
|
writeAddon(path.join(root, `thing.${thirdTriple}.node`));
|
|
const loader = `
|
|
if (process.platform === '${process.platform}') {
|
|
module.exports = require('./thing.${triple}.node');
|
|
} else if (process.platform === 'freebsd') {
|
|
module.exports = require('./thing.${otherTriple}.node');
|
|
} else if (process.platform === 'android') {
|
|
module.exports = require('./thing.${thirdTriple}.node');
|
|
} else {
|
|
throw new Error('unsupported');
|
|
}
|
|
`;
|
|
const code = transform(loader, { filename: path.join(root, 'index.js') });
|
|
expect(callsIn(code)).toHaveLength(1);
|
|
expect(callsIn(code)[0].path).toBe(path.join(root, `thing.${triple}.node`));
|
|
expect(code).toContain('module.exports');
|
|
expect(code).not.toContain('process.platform');
|
|
});
|
|
|
|
test('R6 resolves the per-platform package form', () => {
|
|
const root = scratch();
|
|
// A Linux host named explicitly, so the package that has to exist is the
|
|
// one the loader names for Linux regardless of where this test runs.
|
|
const triple = 'linux-x64-gnu';
|
|
const packageName = `@scope/thing-${triple}`;
|
|
const addon = path.join(
|
|
root,
|
|
'node_modules/@scope',
|
|
`thing-${triple}`,
|
|
'thing.node',
|
|
);
|
|
// The host is named as linux/x64 below, so the bytes must be an ELF for x64
|
|
// even when this test runs on macOS.
|
|
writeAddonFor(addon, 'linux', 'x64');
|
|
write(
|
|
path.join(root, `node_modules/@scope/thing-${triple}/package.json`),
|
|
JSON.stringify({ name: packageName, version: '1.0.0', main: 'thing.node' }),
|
|
);
|
|
const loader = `
|
|
if (process.platform === 'linux') {
|
|
module.exports = require('@scope/thing-linux-x64-gnu');
|
|
} else if (process.platform === 'darwin') {
|
|
module.exports = require('@scope/thing-darwin-arm64');
|
|
} else if (process.platform === 'win32') {
|
|
module.exports = require('@scope/thing-win32-x64-msvc');
|
|
}
|
|
`;
|
|
const code = transform(loader, {
|
|
filename: path.join(root, 'index.js'),
|
|
hostPlatform: 'linux',
|
|
hostArch: 'x64',
|
|
});
|
|
expect(callsIn(code)).toHaveLength(1);
|
|
expect(callsIn(code)[0].path).toBe(addon);
|
|
});
|
|
|
|
test('R6 leaves a file that names fewer than three platforms to R1', () => {
|
|
const root = scratch();
|
|
// Two platforms only, so this is not a napi-rs loader and R1 alone rewrites
|
|
// it. The host's own triple is one of them, so the rewrite really resolves.
|
|
const hostTriple = hostNapiRsTriple();
|
|
const foreignTriple = 'darwin-arm64';
|
|
writeAddon(path.join(root, `thing.${hostTriple}.node`));
|
|
writeAddonFor(
|
|
path.join(root, `thing.${foreignTriple}.node`),
|
|
'darwin',
|
|
'arm64',
|
|
);
|
|
const source = `
|
|
if (process.platform === '${process.platform}') {
|
|
module.exports = require('./thing.${hostTriple}.node');
|
|
} else {
|
|
module.exports = require('./thing.${foreignTriple}.node');
|
|
}
|
|
`;
|
|
const code = transform(source, { filename: path.join(root, 'index.js') });
|
|
// Not a napi-rs loader, so the module body is untouched: the `process`
|
|
// reference only means something where a platform branch survives, and R1
|
|
// rewrote the requires it could resolve.
|
|
expect(code).toContain(`process.platform === '${process.platform}'`);
|
|
expect(code).toMatch(/module\.exports = _requireNodeLibrary\(/u);
|
|
});
|
|
|
|
test('R6 leaves a matching file alone when no host binary exists', () => {
|
|
const root = scratch();
|
|
const source = `
|
|
if (process.platform === 'freebsd') {
|
|
module.exports = require('./thing.freebsd-x64.node');
|
|
} else if (process.platform === 'android') {
|
|
module.exports = require('./thing.android-arm64.node');
|
|
} else if (process.platform === 'openharmony') {
|
|
module.exports = require('./thing.openharmony-arm64.node');
|
|
}
|
|
`;
|
|
const code = transform(source, { filename: path.join(root, 'index.js') });
|
|
expect(code).not.toContain('_requireNodeLibrary');
|
|
});
|
|
|
|
test('R4/R5/R6 are no-ops off gpui', () => {
|
|
const root = scratch();
|
|
writeNodeGypBuildPackage(root, [hostPrebuildTag()]);
|
|
const source = [
|
|
"require('node-gyp-build')(__dirname);",
|
|
"require('bindings')('thing');",
|
|
].join('\n');
|
|
const code = transform(source, {
|
|
filename: path.join(root, 'index.js'),
|
|
platform: 'ios',
|
|
});
|
|
expect(code).toContain("require('node-gyp-build')(__dirname)");
|
|
expect(code).toContain("require('bindings')('thing')");
|
|
expect(code).not.toContain('_requireNodeLibrary');
|
|
});
|
|
|
|
test('R4/R6 use the hostPlatform/hostArch options', () => {
|
|
const root = scratch();
|
|
// `node-gyp-build` spells the Linux tag `linux-x64`; the napi-rs spelling
|
|
// `linux-x64-gnu` is a different directory and must not be picked.
|
|
const directory = path.join(root, 'linux-only');
|
|
writeAddonFor(
|
|
path.join(directory, 'prebuilds/linux-x64-gnu/thing.node'),
|
|
'linux',
|
|
'x64',
|
|
);
|
|
write(
|
|
path.join(directory, 'package.json'),
|
|
JSON.stringify({ name: 'linux-only', version: '1.0.0' }),
|
|
);
|
|
const missing = transform("require('node-gyp-build')(__dirname);", {
|
|
filename: path.join(directory, 'index.js'),
|
|
hostPlatform: 'linux',
|
|
hostArch: 'x64',
|
|
});
|
|
expect(missing).toContain('MODULE_NOT_FOUND');
|
|
|
|
writeAddonFor(
|
|
path.join(directory, 'prebuilds/linux-x64/thing.node'),
|
|
'linux',
|
|
'x64',
|
|
);
|
|
const resolved = transform("require('node-gyp-build')(__dirname);", {
|
|
filename: path.join(directory, 'index.js'),
|
|
hostPlatform: 'linux',
|
|
hostArch: 'x64',
|
|
});
|
|
expect(callsIn(resolved)[0].path).toBe(
|
|
path.join(directory, 'prebuilds/linux-x64/thing.node'),
|
|
);
|
|
});
|
|
|
|
test('R4 handles path.resolve(__dirname), as bcrypt writes it', () => {
|
|
const root = scratch();
|
|
const tag = hostPrebuildTag();
|
|
// bcrypt ships `prebuilds/<platform>-<arch>/<name>.node`, with a `glibc`
|
|
// tag on Linux builds.
|
|
const fileName =
|
|
process.platform === 'linux' ? 'thing.glibc.node' : 'thing.node';
|
|
if (process.platform === 'linux') {
|
|
writeAddon(path.join(root, 'prebuilds', tag, fileName));
|
|
} else {
|
|
writeAddon(path.join(root, 'prebuilds', tag, fileName));
|
|
}
|
|
const code = transform(
|
|
"const path = require('path');\nmodule.exports = require('node-gyp-build')(path.resolve(__dirname));",
|
|
{ filename: path.join(root, 'index.js') },
|
|
);
|
|
expect(callsIn(code)).toHaveLength(1);
|
|
expect(callsIn(code)[0].path).toBe(
|
|
path.join(root, 'prebuilds', tag, fileName),
|
|
);
|
|
});
|
|
|
|
test('R4 resolves path.resolve(__dirname, "sub")', () => {
|
|
const root = scratch();
|
|
const fixture = path.join(root, 'native');
|
|
writeNodeGypBuildPackage(fixture, [hostPrebuildTag()]);
|
|
const code = transform(
|
|
"module.exports = require('node-gyp-build')(require('path').resolve(__dirname, 'native'));",
|
|
{ filename: path.join(root, 'index.js') },
|
|
);
|
|
expect(callsIn(code)[0].path).toBe(
|
|
path.join(fixture, 'prebuilds', hostPrebuildTag(), 'thing.node'),
|
|
);
|
|
});
|
|
|
|
test('R4 accepts a host arch inside a + joined directory', () => {
|
|
const root = scratch();
|
|
const platform = process.platform;
|
|
const arch = process.arch;
|
|
const other = arch === 'arm64' ? 'x64' : 'arm64';
|
|
// The layout microtime ships: one universal binary for a two-arch directory.
|
|
// The host arch is one of the two, so this is a valid host artifact.
|
|
writeAddonFor(
|
|
path.join(
|
|
root,
|
|
'prebuilds',
|
|
`${platform}-${other}+${arch}`,
|
|
'node.napi.node',
|
|
),
|
|
platform,
|
|
arch,
|
|
);
|
|
const code = transform("require('node-gyp-build')(__dirname);", {
|
|
filename: path.join(root, 'index.js'),
|
|
});
|
|
expect(callsIn(code)).toHaveLength(1);
|
|
expect(callsIn(code)[0].path).toContain('node.napi.node');
|
|
});
|
|
|
|
test('R4 rejects a + joined directory that excludes the host arch', () => {
|
|
const root = scratch();
|
|
const platform = process.platform;
|
|
const arch = process.arch;
|
|
const other = arch === 'arm64' ? 'x64' : 'arm64';
|
|
// Two architectures, neither of them the host's.
|
|
writeAddon(
|
|
path.join(
|
|
root,
|
|
'prebuilds',
|
|
`${platform}-${other}+${other === 'x64' ? 'ia32' : 'x86'}`,
|
|
'node.napi.node',
|
|
),
|
|
);
|
|
const code = transform("require('node-gyp-build')(__dirname);", {
|
|
filename: path.join(root, 'index.js'),
|
|
});
|
|
expect(code).toContain('MODULE_NOT_FOUND');
|
|
});
|
|
|
|
test('R4 prefers a single-arch directory over a + joined one', () => {
|
|
const root = scratch();
|
|
const platform = process.platform;
|
|
const arch = process.arch;
|
|
// Both directories match the host; the one naming just this arch wins,
|
|
// exactly as node-gyp-build compares tuples.
|
|
writeAddon(path.join(root, 'prebuilds', `${platform}-${arch}`, 'thing.node'));
|
|
writeAddon(
|
|
path.join(root, 'prebuilds', `${platform}-other+${arch}`, 'thing.node'),
|
|
);
|
|
const code = transform("require('node-gyp-build')(__dirname);", {
|
|
filename: path.join(root, 'index.js'),
|
|
});
|
|
expect(callsIn(code)[0].path).toBe(
|
|
path.join(root, 'prebuilds', `${platform}-${arch}`, 'thing.node'),
|
|
);
|
|
});
|
|
|
|
test('R4 accepts a bare node.napi.node, which is runtime-agnostic', () => {
|
|
const root = scratch();
|
|
// A multi-arch directory holding only an ABI-agnostic N-API build, which is
|
|
// what a package that targets Node, Electron and other hosts ships.
|
|
writeAddon(
|
|
path.join(
|
|
root,
|
|
'prebuilds',
|
|
`${process.platform}-${process.arch}`,
|
|
'node.napi.node',
|
|
),
|
|
);
|
|
const code = transform("require('node-gyp-build')(__dirname);", {
|
|
filename: path.join(root, 'index.js'),
|
|
});
|
|
expect(callsIn(code)).toHaveLength(1);
|
|
expect(callsIn(code)[0].path).toContain('node.napi.node');
|
|
});
|
|
|
|
test('R4 rejects an abi-tagged build, which is not an N-API host', () => {
|
|
const root = scratch();
|
|
const directory = path.join(
|
|
root,
|
|
'prebuilds',
|
|
`${process.platform}-${process.arch}`,
|
|
);
|
|
// `node.abi115.node` is bound to Node's ABI, not to N-API, so it must not be
|
|
// picked even though it is a valid binary for this CPU.
|
|
writeAddon(path.join(directory, 'node.abi115.node'));
|
|
const code = transform("require('node-gyp-build')(__dirname);", {
|
|
filename: path.join(root, 'index.js'),
|
|
});
|
|
expect(code).toContain('MODULE_NOT_FOUND');
|
|
});
|
|
|
|
test('R4 honours the glibc/musl file tag on Linux', () => {
|
|
const root = scratch();
|
|
const directory = path.join(root, 'prebuilds', 'linux-x64');
|
|
writeAddonAt(path.join(directory, 'thing.glibc.node'), 'linux', 'x64');
|
|
writeAddonAt(path.join(directory, 'thing.musl.node'), 'linux', 'x64');
|
|
const code = transform("require('node-gyp-build')(__dirname);", {
|
|
filename: path.join(root, 'index.js'),
|
|
hostPlatform: 'linux',
|
|
hostArch: 'x64',
|
|
});
|
|
// The test host is not Alpine, so the glibc build is the one node-gyp-build
|
|
// would load there.
|
|
expect(callsIn(code)[0].path).toBe(path.join(directory, 'thing.glibc.node'));
|
|
});
|
|
|
|
test('R7 resolves a require whose path is a template literal', () => {
|
|
const root = scratch();
|
|
const platform = process.platform;
|
|
const arch = process.arch;
|
|
const tag =
|
|
platform === 'linux' ? `${platform}-${arch}` : `${platform}-${arch}`;
|
|
// The farmhash layout: one binary per `<platform>-<arch>` under `build/`.
|
|
writeAddonFor(path.join(root, 'build', `thing-${tag}.node`), platform, arch);
|
|
writeAddon(
|
|
path.join(root, 'build', 'thing-otheros-x64.node'),
|
|
Buffer.alloc(0),
|
|
);
|
|
const code = transform(
|
|
'module.exports = require(`./build/thing-${platform}-${arch}.node`);',
|
|
{ filename: path.join(root, 'index.js') },
|
|
);
|
|
expect(callsIn(code)).toHaveLength(1);
|
|
expect(callsIn(code)[0].path).toBe(
|
|
path.join(root, 'build', `thing-${tag}.node`),
|
|
);
|
|
});
|
|
|
|
test('R7 skips a candidate naming a foreign target', () => {
|
|
const root = scratch();
|
|
// Only foreign builds exist, so R7 must not pick one.
|
|
writeAddon(path.join(root, 'build', 'thing-win32-x64.node'));
|
|
writeAddon(path.join(root, 'build', 'thing-linux-arm64.node'));
|
|
const code = transform(
|
|
'module.exports = require(`./build/thing-${platform}-${arch}.node`);',
|
|
{
|
|
filename: path.join(root, 'index.js'),
|
|
hostPlatform: 'darwin',
|
|
hostArch: 'arm64',
|
|
},
|
|
);
|
|
expect(code).toContain('MODULE_NOT_FOUND');
|
|
});
|
|
|
|
test('R7 emits a throwing expression when no candidate exists', () => {
|
|
const root = scratch();
|
|
const code = transform(
|
|
'module.exports = require(`./build/thing-${platform}.node`);',
|
|
{ filename: path.join(root, 'index.js') },
|
|
);
|
|
expect(code).toContain('MODULE_NOT_FOUND');
|
|
expect(code).not.toContain('_requireNodeLibrary');
|
|
});
|
|
|
|
test('R7 leaves a template literal that is not a .node path alone', () => {
|
|
const root = scratch();
|
|
const code = transform('const x = `./data/${name}.json`;', {
|
|
filename: path.join(root, 'index.js'),
|
|
});
|
|
expect(code).toContain('const x = `./data/${name}.json`');
|
|
});
|
|
|
|
// --- Part H: the host-binary check accepts only a host binary -----------------
|
|
//
|
|
// `isPeForArch` answers `null` for "not a PE file at all", `false` for "a PE for
|
|
// another machine" and `true` for a match. The caller used `!== false`, so a
|
|
// `null` — a Linux ELF or a macOS Mach-O — was accepted on a Windows host. The
|
|
// ELF and Mach-O branches already required `=== true`; these tests pin the same
|
|
// rule for Windows, and pin it on every host by naming the platform.
|
|
|
|
/** A file whose first bytes are not any known executable format. */
|
|
function notAnExecutable() {
|
|
return Buffer.from('this is not a native addon, it is text', 'utf8');
|
|
}
|
|
|
|
test('on win32 an ELF file is rejected', () => {
|
|
const root = scratch();
|
|
const addon = path.join(root, 'linux.node');
|
|
write(addon, elfHeader(0x3e));
|
|
expect(isHostPrebuiltNodeAddon(addon, 'win32', 'x64')).toBe(false);
|
|
});
|
|
|
|
test('on win32 a Mach-O file is rejected', () => {
|
|
const root = scratch();
|
|
const addon = path.join(root, 'darwin.node');
|
|
write(addon, machoHeader(0x01000007));
|
|
expect(isHostPrebuiltNodeAddon(addon, 'win32', 'x64')).toBe(false);
|
|
});
|
|
|
|
test('on win32 a file that is not an executable at all is rejected', () => {
|
|
const root = scratch();
|
|
const addon = path.join(root, 'text.node');
|
|
write(addon, notAnExecutable());
|
|
// The case the bug let through: not a PE, so `isPeForArch` answers null.
|
|
expect(isHostPrebuiltNodeAddon(addon, 'win32', 'x64')).toBe(false);
|
|
});
|
|
|
|
test('on win32 a PE for the other architecture is rejected', () => {
|
|
const root = scratch();
|
|
const addon = path.join(root, 'other-arch.node');
|
|
write(addon, peHeader(0xaa64));
|
|
expect(isHostPrebuiltNodeAddon(addon, 'win32', 'x64')).toBe(false);
|
|
});
|
|
|
|
test('on win32 a PE for the host architecture is accepted', () => {
|
|
const root = scratch();
|
|
const addon = path.join(root, 'host.node');
|
|
write(addon, peHeader(0x8664));
|
|
expect(isHostPrebuiltNodeAddon(addon, 'win32', 'x64')).toBe(true);
|
|
});
|
|
|
|
test('a rejected foreign binary becomes a MODULE_NOT_FOUND require', () => {
|
|
const root = scratch();
|
|
const addon = path.join(root, 'linux.node');
|
|
write(addon, elfHeader(0x3e));
|
|
const code = transform("require('./linux.node');", {
|
|
filename: path.join(root, 'index.js'),
|
|
hostPlatform: 'win32',
|
|
hostArch: 'x64',
|
|
});
|
|
expect(code).toContain('MODULE_NOT_FOUND');
|
|
expect(code).not.toContain('_requireNodeLibrary');
|
|
});
|