AGC 随包 Node 支持 universal 双架构运行时
- stage-node-runtime 新增 targetRuntimes:universal 展开为 aarch64/x86_64 两份单架构运行时 - 新增 runtimeSourceForTarget:宿主架构取当前构建 Node,其它架构必须由 AGC_NODE_RUNTIME_<PLATFORM>_<ARCH>_PATH 显式提供,缺失即失败关闭 - 把 stageNodeRuntime 拆成 inspectRuntimeSource(只读校验)+ writeRuntimeBundle(落盘),universal 在写出任何目录前完成全部来源校验与版本一致性核对 - stageNodeRuntimeForTarget 单架构仍写扁平 node-runtime/,universal 按架构写 node-runtime/<platform>-<arch>/ - build-release 默认按发布目标 stage,universal 构建不再只带宿主架构的运行时 - environment_check resolve_at 在扁平目录之后按当前运行架构查找 <platform>-<arch> 子目录,两份候选都要过清单校验,存在但损坏则失败关闭 - 覆盖双架构 staging、缺非宿主运行时、版本不一致、按运行架构解析等用例
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
||||
defaultEditorFeatures,
|
||||
withDefaultCargoFeatures,
|
||||
} from './cargo-features.mjs';
|
||||
import { stageNodeRuntime } from './stage-node-runtime.mjs';
|
||||
import { stageNodeRuntimeForTarget } from './stage-node-runtime.mjs';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。
|
||||
@@ -459,7 +459,8 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) {
|
||||
export function runTauriBuild(
|
||||
args = [],
|
||||
context = resolveReleaseContext(args),
|
||||
{ spawn = spawnSync, stageRuntime = stageNodeRuntime } = {},
|
||||
// 默认按发布目标 stage:universal 需要两份架构运行时,单架构目标行为不变。
|
||||
{ spawn = spawnSync, stageRuntime = stageNodeRuntimeForTarget } = {},
|
||||
) {
|
||||
if (
|
||||
explicitBuildTarget(args) &&
|
||||
|
||||
@@ -76,6 +76,33 @@ function run(command, args) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单独执行随包 Node 分片:分片架构与宿主一致时直接跑,不一致时用 `arch` 强制
|
||||
* (arm64 机器上的 x86_64 分片依赖 Rosetta,与 `.app` 双架构 smoke 的前提相同)。
|
||||
*/
|
||||
function runNodeSlice(directory, args) {
|
||||
const native = process.arch === 'arm64' ? 'arm64' : 'x86_64';
|
||||
const binary = path.join(directory, 'node');
|
||||
const [command, argv] =
|
||||
architecture === native
|
||||
? [binary, args]
|
||||
: ['/usr/bin/arch', [`-${architecture}`, binary, ...args]];
|
||||
const result = spawnSync(command, argv, {
|
||||
cwd: root,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 120_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
assert.ifError(result.error);
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`${directory} 随包 Node 执行失败:${result.stderr || result.stdout}`,
|
||||
);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* APFS 上优先用 `ditto --clone`:整包按区块克隆,秒级完成且几乎不占额外空间。
|
||||
* 跨卷或非 APFS 时回退到真实复制;两种路径都必须产出可独立改动的副本,
|
||||
@@ -248,37 +275,84 @@ try {
|
||||
}
|
||||
}
|
||||
assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md')));
|
||||
// 随包 Node:单架构构建是扁平目录,universal 构建把两套架构运行时并列放进
|
||||
// `game-runtime/node/<platform>-<arch>/`(与 Codex 侧车同形态,由 Rust 侧按运行架构选择)。
|
||||
// 两套清单在本函数里都做完整性与架构校验;只执行与本次 smoke 架构一致的那一份,
|
||||
// 另一份由另一次架构的 smoke 覆盖(build-macos-ci.mjs 会对 arm64 / x86_64 各跑一次)。
|
||||
const nodeRoot = path.join(resources, 'game-runtime/node');
|
||||
const nodeManifest = JSON.parse(
|
||||
fs.readFileSync(path.join(nodeRoot, 'manifest.json'), 'utf8'),
|
||||
);
|
||||
assert.equal(nodeManifest.schemaVersion, 'agc-node-runtime.v1');
|
||||
assert.equal(nodeManifest.platform, 'darwin');
|
||||
assert.equal(nodeManifest.arch, process.arch);
|
||||
const runtimeFiles = fs
|
||||
.readdirSync(nodeRoot, { recursive: true })
|
||||
.filter(
|
||||
(file) =>
|
||||
fs.statSync(path.join(nodeRoot, file)).isFile() &&
|
||||
file !== 'manifest.json',
|
||||
const nodeSlices = requireUniversal
|
||||
? ['darwin-arm64', 'darwin-x64'].map((platform) => ({
|
||||
platform,
|
||||
directory: path.join(nodeRoot, platform),
|
||||
}))
|
||||
: [{ platform: null, directory: nodeRoot }];
|
||||
for (const slice of nodeSlices) {
|
||||
const { directory } = slice;
|
||||
const nodeManifest = JSON.parse(
|
||||
fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'),
|
||||
);
|
||||
assert.deepEqual(runtimeFiles.sort(), Object.keys(nodeManifest.files).sort());
|
||||
for (const [file, digest] of Object.entries(nodeManifest.files)) {
|
||||
assert.equal(await hashFile(path.join(nodeRoot, file)), digest, file);
|
||||
assert.equal(nodeManifest.schemaVersion, 'agc-node-runtime.v1');
|
||||
assert.equal(nodeManifest.platform, 'darwin');
|
||||
if (slice.platform) {
|
||||
// 目录名与清单架构必须一致:错位会让用户拿到跑不起来的运行时。
|
||||
assert.equal(`darwin-${nodeManifest.arch}`, slice.platform);
|
||||
assert.ok(
|
||||
/^(arm64|x64)$/u.test(nodeManifest.arch),
|
||||
`未知运行架构:${nodeManifest.arch}`,
|
||||
);
|
||||
} else {
|
||||
assert.equal(nodeManifest.arch, process.arch);
|
||||
}
|
||||
const runtimeFiles = fs
|
||||
.readdirSync(directory, { recursive: true })
|
||||
.filter(
|
||||
(file) =>
|
||||
fs.statSync(path.join(directory, file)).isFile() &&
|
||||
file !== 'manifest.json',
|
||||
);
|
||||
assert.deepEqual(
|
||||
runtimeFiles.sort(),
|
||||
Object.keys(nodeManifest.files).sort(),
|
||||
slice.platform ?? 'flat',
|
||||
);
|
||||
for (const [file, digest] of Object.entries(nodeManifest.files)) {
|
||||
assert.equal(await hashFile(path.join(directory, file)), digest, file);
|
||||
}
|
||||
assert.ok(nodeManifest.files['NODE-LICENSE']);
|
||||
assert.ok(nodeManifest.files['node_modules/npm/LICENSE']);
|
||||
fs.accessSync(path.join(directory, 'node'), fs.constants.X_OK);
|
||||
// 二进制本身必须是本分片的单一架构:官方发行版不做 universal,lipo 能直接证明。
|
||||
const sliceArchitecture = spawnSync(
|
||||
'/usr/bin/lipo',
|
||||
['-archs', path.join(directory, 'node')],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(sliceArchitecture.status, 0, 'lipo -archs node');
|
||||
assert.equal(
|
||||
sliceArchitecture.stdout.trim(),
|
||||
nodeManifest.arch === 'x64' ? 'x86_64' : 'arm64',
|
||||
'随包 Node 的二进制架构必须等于清单架构',
|
||||
);
|
||||
// 只有与本次 smoke 架构一致的分片才执行;另一架构留给对应的那次 smoke。
|
||||
if (
|
||||
!requireUniversal ||
|
||||
nodeManifest.arch === (architecture === 'arm64' ? 'arm64' : 'x64')
|
||||
) {
|
||||
assert.equal(
|
||||
runNodeSlice(directory, ['--version']),
|
||||
nodeManifest.nodeVersion,
|
||||
`${slice.platform ?? 'flat'} node --version`,
|
||||
);
|
||||
assert.equal(
|
||||
runNodeSlice(directory, [
|
||||
path.join(directory, 'node_modules/npm/bin/npm-cli.js'),
|
||||
'--version',
|
||||
]),
|
||||
nodeManifest.npmVersion,
|
||||
`${slice.platform ?? 'flat'} npm --version`,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert.ok(nodeManifest.files['NODE-LICENSE']);
|
||||
assert.ok(nodeManifest.files['node_modules/npm/LICENSE']);
|
||||
assert.equal(
|
||||
run(path.join(nodeRoot, 'node'), ['--version']).stdout.trim(),
|
||||
nodeManifest.nodeVersion,
|
||||
);
|
||||
assert.equal(
|
||||
run(path.join(nodeRoot, 'node'), [
|
||||
path.join(nodeRoot, 'node_modules/npm/bin/npm-cli.js'),
|
||||
'--version',
|
||||
]).stdout.trim(),
|
||||
nodeManifest.npmVersion,
|
||||
);
|
||||
const plugin = path.join(resources, 'plugins/agc-cocos-editor');
|
||||
for (const file of [
|
||||
'plugin.json',
|
||||
@@ -294,8 +368,13 @@ try {
|
||||
/(^|\/)(\.env[^/]*|auth\.json|target|\.git)(\/|$)|\.(exe|dll)$/.test(
|
||||
file,
|
||||
) ||
|
||||
// 只有随包 Node 自带的 npm 允许出现 node_modules;两种布局都要放行:
|
||||
// 单架构的 `game-runtime/node/node_modules/npm` 与 universal 的
|
||||
// `game-runtime/node/<platform>-<arch>/node_modules/npm`。
|
||||
(/(^|\/)node_modules(\/|$)/.test(file) &&
|
||||
!file.startsWith('game-runtime/node/node_modules/npm')),
|
||||
!/^game-runtime\/node\/((darwin-(arm64|x64))\/)?node_modules\/npm(\/|$)/u.test(
|
||||
file,
|
||||
)),
|
||||
),
|
||||
);
|
||||
assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version);
|
||||
|
||||
@@ -6,6 +6,13 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
export const nodeRuntimeSchema = 'agc-node-runtime.v1';
|
||||
// 单架构目标写扁平目录;universal 在此目录下按架构分目录,见 stageNodeRuntimeForTarget。
|
||||
const defaultRuntimeDestination = path.join(
|
||||
appRoot,
|
||||
'src-tauri',
|
||||
'resources',
|
||||
'node-runtime',
|
||||
);
|
||||
|
||||
export function readInstalledNodeLicense(
|
||||
version,
|
||||
@@ -66,10 +73,116 @@ export function targetRuntime(target) {
|
||||
'x86_64-apple-darwin': ['darwin', 'x64'],
|
||||
};
|
||||
const value = targets[target];
|
||||
if (!value) throw new Error(`Node 运行时不支持发布目标:${target}`);
|
||||
// universal 不是单份运行时能表达的目标:它必须按架构展开成两份,见 targetRuntimes。
|
||||
if (!value)
|
||||
throw new Error(
|
||||
target === 'universal-apple-darwin'
|
||||
? 'Node 运行时不能直接按 universal-apple-darwin 制作:双架构请用 stageNodeRuntimeForTarget 按架构展开'
|
||||
: `Node 运行时不支持发布目标:${target}`,
|
||||
);
|
||||
return { platform: value[0], arch: value[1] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布目标需要的随包运行时列表:universal 需要两份单架构运行时,
|
||||
* 其余目标仍然是一份(保持既有扁平布局与行为)。
|
||||
*/
|
||||
export function targetRuntimes(target) {
|
||||
if (target === 'universal-apple-darwin')
|
||||
return ['aarch64-apple-darwin', 'x86_64-apple-darwin'];
|
||||
return [target];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按架构选择 staging 输入。
|
||||
*
|
||||
* 契约(见实施计划):发布包只从**本机已安装且与目标平台/架构一致**的工具链取材,
|
||||
* 不得使用项目内或相对 PATH 的伪造运行时。宿主架构直接复用当前 Node;
|
||||
* 其它架构必须由构建节点显式提供,缺失即失败关闭——不静默跳过、不回退系统 Node。
|
||||
*/
|
||||
export function runtimeSourceForTarget(
|
||||
archTarget,
|
||||
{
|
||||
env = process.env,
|
||||
nodePath,
|
||||
npmCli,
|
||||
licensePath,
|
||||
hostNodePath = process.execPath,
|
||||
hostNpmCli = process.env.npm_execpath,
|
||||
hostLicensePath = env.AGC_NODE_LICENSE_PATH,
|
||||
hostPlatform = process.platform,
|
||||
hostArch = process.arch,
|
||||
} = {},
|
||||
) {
|
||||
const native = targetRuntime(archTarget);
|
||||
if (native.platform === hostPlatform && native.arch === hostArch) {
|
||||
return {
|
||||
nodePath: nodePath ?? hostNodePath,
|
||||
npmCli: npmCli ?? hostNpmCli,
|
||||
licensePath: licensePath ?? hostLicensePath,
|
||||
};
|
||||
}
|
||||
const key = `${native.platform}_${native.arch}`.toUpperCase();
|
||||
const configured = env[`AGC_NODE_RUNTIME_${key}_PATH`]?.trim();
|
||||
if (!configured) {
|
||||
throw new Error(
|
||||
`缺少 ${native.platform}/${native.arch} 的 Node 运行时:该架构不是构建宿主,` +
|
||||
`请先在本机安装同架构 Node,再用 AGC_NODE_RUNTIME_${key}_PATH 指向其 bin/node`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
nodePath: configured,
|
||||
npmCli: undefined,
|
||||
licensePath: env[`AGC_NODE_RUNTIME_${key}_LICENSE_PATH`]?.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按发布目标 stage 运行时资源。
|
||||
*
|
||||
* 单架构目标沿用既有扁平目录(`node-runtime/`),universal 目标写进
|
||||
* `node-runtime/<platform>-<arch>/`——与捆绑 Codex 的分架构目录同一形态,
|
||||
* 由 Rust 侧按当前运行架构选择;构建期资源映射仍是整目录映射,无需按架构分叉。
|
||||
*
|
||||
* 先解析并校验**全部**来源,再逐个落盘:非宿主架构的运行时缺失、平台/架构不符、
|
||||
* 两套版本不一致,都在写出任何运行时目录之前失败,不留下半套资源冒充发布内容。
|
||||
*/
|
||||
export function stageNodeRuntimeForTarget(
|
||||
target,
|
||||
{ destination = defaultRuntimeDestination, ...options } = {},
|
||||
) {
|
||||
const targets = targetRuntimes(target);
|
||||
const plans = targets.map((archTarget) => {
|
||||
const native = targetRuntime(archTarget);
|
||||
return {
|
||||
destination:
|
||||
targets.length === 1
|
||||
? destination
|
||||
: path.join(destination, `${native.platform}-${native.arch}`),
|
||||
inspected: inspectRuntimeSource(archTarget, {
|
||||
...options,
|
||||
...runtimeSourceForTarget(archTarget, options),
|
||||
}),
|
||||
};
|
||||
});
|
||||
// 两个切片必须是同一套 Node:版本不一致意味着其中一份被换过,
|
||||
// 用户在不同架构上会拿到行为不同的工具链。
|
||||
const identities = new Set(
|
||||
plans.map(
|
||||
({ inspected }) =>
|
||||
`${inspected.info.version}|${inspected.npmPackage.version}`,
|
||||
),
|
||||
);
|
||||
if (identities.size !== 1) {
|
||||
throw new Error(
|
||||
`多架构 Node 运行时版本不一致:${[...identities].join(' / ')}`,
|
||||
);
|
||||
}
|
||||
return plans.map(({ inspected, destination }) =>
|
||||
writeRuntimeBundle(inspected, { destination }),
|
||||
);
|
||||
}
|
||||
|
||||
function inside(root, file) {
|
||||
const relative = path.relative(root, file);
|
||||
return (
|
||||
@@ -212,13 +325,19 @@ export function assertPortableMacNode(output) {
|
||||
}
|
||||
}
|
||||
|
||||
export function stageNodeRuntime(
|
||||
/**
|
||||
* 只读校验一份运行时来源,返回 staging 需要的全部事实。
|
||||
*
|
||||
* 与写盘分离的原因:多架构发布必须能在写出任何文件之前发现「另一份来源缺失或
|
||||
* 与目标不符」。校验口径保持原样——平台/架构必须等于目标、macOS 二进制只能链接
|
||||
* 系统动态库、npm 的身份与实际版本必须一致、许可必须来自发行版本体。
|
||||
*/
|
||||
export function inspectRuntimeSource(
|
||||
target,
|
||||
{
|
||||
nodePath = process.execPath,
|
||||
npmCli = process.env.npm_execpath,
|
||||
licensePath = process.env.AGC_NODE_LICENSE_PATH,
|
||||
destination = path.join(appRoot, 'src-tauri', 'resources', 'node-runtime'),
|
||||
execute = execFileSync,
|
||||
installedLicense = readInstalledNodeLicense,
|
||||
} = {},
|
||||
@@ -309,6 +428,14 @@ export function stageNodeRuntime(
|
||||
throw new Error('Node LICENSE 不包含发行许可');
|
||||
if (!fs.statSync(path.join(npmRoot, 'LICENSE')).isFile())
|
||||
throw new Error('npm 缺少 LICENSE');
|
||||
return { native, node, npmRoot, npmPackage, info, license, licenseName };
|
||||
}
|
||||
|
||||
/** 把已校验的来源写成一份发布资源,返回清单。 */
|
||||
function writeRuntimeBundle(
|
||||
{ native, node, npmRoot, npmPackage, info, license, licenseName },
|
||||
{ destination },
|
||||
) {
|
||||
// 临时同级目录完成后才替换资源;不污染 Node 安装或项目工作区。
|
||||
const requestedDestination = path.resolve(destination);
|
||||
if (requestedDestination === path.dirname(requestedDestination))
|
||||
@@ -394,3 +521,26 @@ export function stageNodeRuntime(
|
||||
cleanupStaging(staging, parent, stagingPrefix, stagingIdentity);
|
||||
}
|
||||
}
|
||||
|
||||
export function stageNodeRuntime(
|
||||
target,
|
||||
{
|
||||
nodePath = process.execPath,
|
||||
npmCli = process.env.npm_execpath,
|
||||
licensePath = process.env.AGC_NODE_LICENSE_PATH,
|
||||
destination = defaultRuntimeDestination,
|
||||
execute = execFileSync,
|
||||
installedLicense = readInstalledNodeLicense,
|
||||
} = {},
|
||||
) {
|
||||
return writeRuntimeBundle(
|
||||
inspectRuntimeSource(target, {
|
||||
nodePath,
|
||||
npmCli,
|
||||
licensePath,
|
||||
execute,
|
||||
installedLicense,
|
||||
}),
|
||||
{ destination },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
assertPortableMacNode,
|
||||
readInstalledNodeLicense,
|
||||
stageNodeRuntime,
|
||||
stageNodeRuntimeForTarget,
|
||||
targetRuntime,
|
||||
targetRuntimes,
|
||||
} from './stage-node-runtime.mjs';
|
||||
|
||||
function fixture(run) {
|
||||
@@ -281,7 +283,11 @@ test('native target and macOS dynamic dependency policy reject nonportable Node'
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
});
|
||||
assert.throws(() => targetRuntime('universal-apple-darwin'), /不支持/u);
|
||||
// universal 不是单份运行时目标:必须报出「按架构展开」而不是笼统的「不支持」。
|
||||
assert.throws(
|
||||
() => targetRuntime('universal-apple-darwin'),
|
||||
/stageNodeRuntimeForTarget/u,
|
||||
);
|
||||
assertPortableMacNode(
|
||||
'/node:\n\t/usr/lib/libSystem.B.dylib (compatibility version 1)\n',
|
||||
);
|
||||
@@ -293,3 +299,146 @@ test('native target and macOS dynamic dependency policy reject nonportable Node'
|
||||
/非系统动态库/u,
|
||||
);
|
||||
});
|
||||
|
||||
// 双架构夹具:每个架构一份来源目录,execute 按被查询的二进制回报对应架构。
|
||||
function universalFixture(run) {
|
||||
const root = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'agc-node-universal-test-'),
|
||||
);
|
||||
const sourceFor = (arch, nodeVersion) => {
|
||||
const dir = path.join(root, `source-${arch}`);
|
||||
const npm = path.join(dir, 'node_modules/npm');
|
||||
fs.mkdirSync(path.join(npm, 'bin'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'node'), `node-${arch}`);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'LICENSE'),
|
||||
'Node.js\nPermission is hereby granted',
|
||||
);
|
||||
fs.writeFileSync(path.join(npm, 'LICENSE'), 'npm distribution license');
|
||||
fs.writeFileSync(
|
||||
path.join(npm, 'package.json'),
|
||||
JSON.stringify({ name: 'npm', version: '11.0.0' }),
|
||||
);
|
||||
for (const name of ['npm', 'npx'])
|
||||
fs.writeFileSync(path.join(npm, `bin/${name}-cli.js`), `// ${arch}`);
|
||||
return {
|
||||
nodePath: path.join(dir, 'node'),
|
||||
npmCli: path.join(npm, 'bin/npm-cli.js'),
|
||||
licensePath: path.join(dir, 'LICENSE'),
|
||||
nodeVersion,
|
||||
};
|
||||
};
|
||||
try {
|
||||
return run(root, {
|
||||
arm64: sourceFor('arm64', 'v22.23.2'),
|
||||
x64: sourceFor('x64', 'v22.23.2'),
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function optionsFor(root, sources, overrides = {}) {
|
||||
const archOf = (file) => (file.includes('source-x64') ? 'x64' : 'arm64');
|
||||
return {
|
||||
destination: path.join(root, 'resources/node-runtime'),
|
||||
hostPlatform: 'darwin',
|
||||
hostArch: 'arm64',
|
||||
hostNodePath: sources.arm64.nodePath,
|
||||
hostNpmCli: sources.arm64.npmCli,
|
||||
env: {
|
||||
AGC_NODE_RUNTIME_DARWIN_X64_PATH: sources.x64.nodePath,
|
||||
AGC_NODE_RUNTIME_DARWIN_X64_LICENSE_PATH: sources.x64.licensePath,
|
||||
},
|
||||
installedLicense() {
|
||||
throw new Error('no installed fixture license');
|
||||
},
|
||||
execute(file, args) {
|
||||
if (file === '/usr/bin/otool')
|
||||
return `\t/usr/lib/libSystem.B.dylib\n\t/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation\n`;
|
||||
if (args[0] === '-p') {
|
||||
const arch = archOf(file);
|
||||
return JSON.stringify({
|
||||
platform: 'darwin',
|
||||
arch,
|
||||
version: sources[arch].nodeVersion,
|
||||
});
|
||||
}
|
||||
return '11.0.0';
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('universal stages one runtime per architecture with architecture-correct manifests', () =>
|
||||
universalFixture((root, sources) => {
|
||||
assert.deepEqual(targetRuntimes('universal-apple-darwin'), [
|
||||
'aarch64-apple-darwin',
|
||||
'x86_64-apple-darwin',
|
||||
]);
|
||||
const manifests = stageNodeRuntimeForTarget(
|
||||
'universal-apple-darwin',
|
||||
optionsFor(root, sources),
|
||||
);
|
||||
assert.deepEqual(
|
||||
manifests.map((manifest) => `${manifest.platform}-${manifest.arch}`),
|
||||
['darwin-arm64', 'darwin-x64'],
|
||||
);
|
||||
for (const manifest of manifests) {
|
||||
const directory = path.join(
|
||||
root,
|
||||
'resources/node-runtime',
|
||||
`${manifest.platform}-${manifest.arch}`,
|
||||
);
|
||||
assert.equal(
|
||||
JSON.parse(
|
||||
fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'),
|
||||
).arch,
|
||||
manifest.arch,
|
||||
);
|
||||
assert.ok(fs.existsSync(path.join(directory, 'node')));
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(directory, 'node_modules/npm/LICENSE')),
|
||||
);
|
||||
}
|
||||
// 单架构目标仍写扁平目录(与既有发布一致),不产生分架构子目录。
|
||||
const flat = stageNodeRuntimeForTarget(
|
||||
'aarch64-apple-darwin',
|
||||
optionsFor(root, sources, { destination: path.join(root, 'flat') }),
|
||||
);
|
||||
assert.equal(flat.length, 1);
|
||||
assert.ok(fs.existsSync(path.join(root, 'flat/manifest.json')));
|
||||
assert.ok(!fs.existsSync(path.join(root, 'flat/darwin-arm64')));
|
||||
}));
|
||||
|
||||
test('universal fails closed when the non-host architecture runtime is absent', () =>
|
||||
universalFixture((root, sources) => {
|
||||
const options = optionsFor(root, sources, { env: {} });
|
||||
assert.throws(
|
||||
() => stageNodeRuntimeForTarget('universal-apple-darwin', options),
|
||||
/AGC_NODE_RUNTIME_DARWIN_X64_PATH/u,
|
||||
);
|
||||
// 缺失时不得留下半成品目录。
|
||||
assert.ok(!fs.existsSync(path.join(root, 'resources')));
|
||||
}));
|
||||
|
||||
test('universal rejects mismatched Node versions between the two architectures', () =>
|
||||
universalFixture((root, sources) => {
|
||||
const options = optionsFor(root, sources);
|
||||
options.execute = (file, args) => {
|
||||
if (file === '/usr/bin/otool') return '\t/usr/lib/libSystem.B.dylib\n';
|
||||
if (args[0] === '-p')
|
||||
return JSON.stringify({
|
||||
platform: 'darwin',
|
||||
arch: file.includes('source-x64') ? 'x64' : 'arm64',
|
||||
version: file.includes('source-x64') ? 'v22.23.2' : 'v24.0.0',
|
||||
});
|
||||
return '11.0.0';
|
||||
};
|
||||
assert.throws(
|
||||
() => stageNodeRuntimeForTarget('universal-apple-darwin', options),
|
||||
/版本不一致/u,
|
||||
);
|
||||
// 版本核对在写出任何架构之前完成,失败时不留下半套运行时。
|
||||
assert.ok(!fs.existsSync(path.join(root, 'resources')));
|
||||
}));
|
||||
|
||||
@@ -290,15 +290,37 @@ fn resolve_at(
|
||||
let root = root
|
||||
.canonicalize()
|
||||
.map_err(|_| "project-root-unavailable")?;
|
||||
if let Some(bundle) = bundle.filter(|bundle| bundle.exists()) {
|
||||
validate_bundle(bundle)?;
|
||||
return runtime_from_paths(
|
||||
&root,
|
||||
bundle.join(executable_name()),
|
||||
bundle.join("node_modules/npm/bin/npm-cli.js"),
|
||||
"bundled",
|
||||
path,
|
||||
);
|
||||
// 候选顺序:既有扁平布局(单架构构建),其后是按**当前运行架构**命名的子目录
|
||||
// (universal 构建把两套运行时并列放进去)。两个候选都要过 manifest 的平台/架构
|
||||
// 校验,因此顺序不会让另一架构的运行时被采用;存在但校验失败则失败关闭,
|
||||
// 绝不因此退回系统 Node。
|
||||
let mut bundle_error: Option<String> = None;
|
||||
if let Some(base) = bundle {
|
||||
let candidates = [
|
||||
base.to_path_buf(),
|
||||
base.join(format!("{}-{}", native_platform(), native_arch())),
|
||||
];
|
||||
for candidate in candidates.iter().filter(|candidate| candidate.exists()) {
|
||||
match validate_bundle(candidate) {
|
||||
Ok(()) => {
|
||||
return runtime_from_paths(
|
||||
&root,
|
||||
candidate.join(executable_name()),
|
||||
candidate.join("node_modules/npm/bin/npm-cli.js"),
|
||||
"bundled",
|
||||
path,
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
if bundle_error.is_none() {
|
||||
bundle_error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(error) = bundle_error {
|
||||
return Err(error);
|
||||
}
|
||||
if !development {
|
||||
return Err("node-runtime-bundle-missing".into());
|
||||
@@ -595,6 +617,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// universal 构建把两套架构运行时并列放在 `<base>/<platform>-<arch>/`:
|
||||
/// 解析必须按当前运行架构选中自己那一份,且另一架构的存在与否不影响结果。
|
||||
#[test]
|
||||
fn universal_bundle_resolves_the_directory_matching_the_running_architecture() {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let bundle = tempfile::tempdir().unwrap();
|
||||
let current = bundle
|
||||
.path()
|
||||
.join(format!("{}-{}", native_platform(), native_arch()));
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
bundle_fixture(¤t);
|
||||
|
||||
let runtime = resolve_at(project.path(), Some(bundle.path()), false, OsStr::new(""))
|
||||
.expect("universal bundle must resolve the running architecture");
|
||||
assert_eq!(runtime.source, "bundled");
|
||||
assert_eq!(
|
||||
runtime.node.canonicalize().unwrap(),
|
||||
current.join(executable_name()).canonicalize().unwrap()
|
||||
);
|
||||
|
||||
// 另一架构的目录即使损坏也不能影响本架构选择。
|
||||
let other = if native_arch() == "arm64" {
|
||||
"x86_64"
|
||||
} else {
|
||||
"arm64"
|
||||
};
|
||||
let other_dir = bundle
|
||||
.path()
|
||||
.join(format!("{}-{}", native_platform(), other));
|
||||
fs::create_dir_all(&other_dir).unwrap();
|
||||
fs::write(other_dir.join("manifest.json"), b"not-json").unwrap();
|
||||
assert_eq!(
|
||||
resolve_at(project.path(), Some(bundle.path()), false, OsStr::new(""))
|
||||
.unwrap()
|
||||
.node
|
||||
.canonicalize()
|
||||
.unwrap(),
|
||||
current.join(executable_name()).canonicalize().unwrap()
|
||||
);
|
||||
|
||||
// 本架构目录缺失而另一架构完整时,绝不采用另一架构的运行时。
|
||||
fs::remove_dir_all(¤t).unwrap();
|
||||
bundle_fixture(&other_dir);
|
||||
assert!(resolve_at(project.path(), Some(bundle.path()), false, OsStr::new("")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn development_runtime_rejects_relative_and_project_path_entries() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user