Files
Genarrative/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs
T
suzmii c0154c2aed
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 15s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 12s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 19s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 15s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 19s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 18s
Project CI / Frontend tests (pull_request) Failing after 8s
Project CI / Repository checks (pull_request) Failing after 16s
Project CI / Native shell tests (pull_request) Failing after 16s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell web tests (pull_request) Failing after 12s
优化Mac构建管线并行度与入口健壮性
并行度参数默认改为8以吃满节点,并在说明里记录其作为rustc jobserver令牌上限的影响
产品名统一从Tauri配置推导,校验脚本从Info.plist读取可执行名,改产品名不再静默失效
隔离smoke改用ditto --clone复制副本,实测整轮6.8秒
Agent工作区守卫改为按目录约定匹配,节点改名后仍成立
签到同时取master以便解析上次发布commit,失败仅降级更新摘要
补充上述行为的回归断言
2026-09-20 21:00:05 +08:00

285 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
// 只操作临时复制品;不启动 GUI、不读取开发机凭据、不访问 Provider。
assert.equal(process.platform, 'darwin', '此验证必须在 macOS 执行');
const source = path.resolve(process.argv[2] || '');
const architecture =
process.argv[3] || (process.arch === 'arm64' ? 'arm64' : 'x86_64');
assert.ok(
['arm64', 'x86_64'].includes(architecture),
'架构只接受 arm64 / x86_64',
);
const requireUniversal = process.argv.includes('--universal');
assert.ok(
source.endsWith('.app') && fs.statSync(source).isDirectory(),
'请传入 .app 绝对路径',
);
const root = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'agc-macos-bundle-')),
);
// 产品名从传入的 .app 推导,不在校验脚本里写死;改名后校验对象仍指向同一个包。
const appBundleName = path.basename(source);
const app = path.join(root, `隔离-${appBundleName}`);
const home = path.join(root, 'home');
const config = path.join(root, 'config');
const tmp = path.join(root, 'tmp');
const codexHome = path.join(root, 'codex-home');
for (const directory of [home, config, tmp, codexHome]) {
fs.mkdirSync(directory, { mode: 0o700 });
}
const env = {
HOME: home,
PATH: '/usr/bin:/bin',
TMPDIR: tmp,
CODEX_HOME: codexHome,
};
function run(command, args) {
// 只强制被测应用切片;本机 Xcode 检查工具可能仅提供宿主架构。
const useSlice = command.startsWith(`${app}${path.sep}`);
const result = spawnSync(
useSlice ? '/usr/bin/arch' : command,
useSlice ? [`-${architecture}`, command, ...args] : args,
{
cwd: root,
env,
encoding: 'utf8',
timeout: 120_000,
maxBuffer: 1024 * 1024,
},
);
assert.ifError(result.error);
return result;
}
/**
* APFS 上优先用 `ditto --clone`:整包按区块克隆,秒级完成且几乎不占额外空间。
* 跨卷或非 APFS 时回退到真实复制;两种路径都必须产出可独立改动的副本,
* 因为「缺组件拒绝」用例会在副本里改名文件。
*/
function copyBundle(from, to) {
const cloned = spawnSync('/usr/bin/ditto', ['--clone', from, to], {
encoding: 'utf8',
});
if (
cloned.status === 0 &&
fs.existsSync(path.join(to, 'Contents/Info.plist'))
) {
return 'clone';
}
fs.cpSync(from, to, { recursive: true });
return 'copy';
}
/** 可执行名以包内 Info.plist 为准:它是稳定契约,但没必要在校验脚本里重复硬编码。 */
function readBundleExecutable(appPath) {
const plist = path.join(appPath, 'Contents/Info.plist');
const result = spawnSync(
'/usr/libexec/PlistBuddy',
['-c', 'Print :CFBundleExecutable', plist],
{ encoding: 'utf8' },
);
const name = (result.stdout ?? '').trim();
assert.ok(
name.length > 0,
`无法从 Info.plist 读取 CFBundleExecutable${plist}`,
);
return name;
}
async function hashFile(file) {
const hash = createHash('sha256');
for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
return hash.digest('hex');
}
async function handshake(executable) {
const child = spawn(executable, ['app-server'], {
cwd: root,
env,
stdio: ['pipe', 'pipe', 'pipe'],
});
let buffered = '';
let stderrBytes = 0;
try {
await new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error('app-server 初始化超时')),
120_000,
);
const finish = (error) => {
clearTimeout(timer);
if (error) reject(error);
else resolve();
};
child.on('error', finish);
child.on('exit', (code) =>
finish(new Error(`app-server 提前退出 ${code}`)),
);
child.stderr.on('data', (chunk) => {
stderrBytes += chunk.length;
if (stderrBytes > 1024 * 1024)
finish(new Error('app-server stderr 超限'));
});
child.stdout.on('data', (chunk) => {
buffered += chunk.toString('utf8');
if (buffered.length > 1024 * 1024)
return finish(new Error('app-server stdout 超限'));
let end;
while ((end = buffered.indexOf('\n')) >= 0) {
const line = buffered.slice(0, end);
buffered = buffered.slice(end + 1);
try {
const message = JSON.parse(line);
if (message.id !== 1) continue;
assert.ok(message.result?.userAgent, '初始化必须返回真实服务身份');
assert.equal(message.error, undefined);
child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`);
finish();
} catch (error) {
finish(error);
}
}
});
child.stdin.on('error', finish);
child.stdin.write(
`${JSON.stringify({
id: 1,
method: 'initialize',
params: {
clientInfo: {
name: 'agc_bundle_smoke',
title: 'AGC bundle smoke',
version: '1',
},
capabilities: { experimentalApi: true },
},
})}\n`,
);
});
} finally {
if (child.exitCode === null && child.signalCode === null) {
await new Promise((resolve) => {
const timer = setTimeout(() => child.kill('SIGKILL'), 3000);
child.once('exit', () => {
clearTimeout(timer);
resolve();
});
child.kill('SIGTERM');
});
}
}
}
try {
const copiedWith = copyBundle(source, app);
const resources = path.join(app, 'Contents/Resources');
const platform = architecture === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
const bundle = path.join(resources, 'coding-agent/mac-native', platform);
const executable = path.join(bundle, 'bin/codex');
const main = path.join(app, 'Contents/MacOS', readBundleExecutable(app));
const mainArchitectures = run('/usr/bin/lipo', ['-archs', main]);
assert.equal(mainArchitectures.status, 0);
assert.ok(mainArchitectures.stdout.split(/\s+/).includes(architecture));
if (requireUniversal) {
assert.deepEqual(mainArchitectures.stdout.trim().split(/\s+/).sort(), [
'arm64',
'x86_64',
]);
for (const platform of ['darwin-arm64', 'darwin-x64']) {
assert.ok(
fs.existsSync(
path.join(
resources,
'coding-agent/mac-native',
platform,
'manifest.json',
),
),
);
}
}
const manifest = JSON.parse(
fs.readFileSync(path.join(bundle, 'manifest.json'), 'utf8'),
);
assert.equal(manifest.schemaVersion, 'genarrative-codex-sidecar.v2');
assert.equal(manifest.platform, platform);
assert.equal(manifest.version, 'codex-cli 0.147.0');
const components = [
'bin/codex',
'bin/codex-code-mode-host',
'codex-path/rg',
'codex-resources/zsh/bin/zsh',
'codex-package.json',
];
assert.deepEqual(Object.keys(manifest.files).sort(), [...components].sort());
for (const component of components) {
const file = path.join(bundle, component);
assert.equal(await hashFile(file), manifest.files[component], component);
if (component !== 'codex-package.json') {
fs.accessSync(file, fs.constants.X_OK);
const arch = run('/usr/bin/lipo', ['-archs', file]);
assert.equal(arch.status, 0, component);
assert.equal(arch.stdout.trim(), architecture, component);
}
}
assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md')));
const plugin = path.join(resources, 'plugins/agc-cocos-editor');
for (const file of [
'plugin.json',
'src/entry.mjs',
'panels/cocos-editor.html',
]) {
assert.ok(fs.existsSync(path.join(plugin, file)), file);
}
const packageFiles = fs.readdirSync(resources, { recursive: true });
assert.ok(
!packageFiles.some((file) =>
/(^|\/)(\.env[^/]*|auth\.json|node_modules|target|\.git)(\/|$)|\.(exe|dll)$/.test(
file,
),
),
);
assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version);
assert.equal(
run(path.join(bundle, 'codex-path/rg'), ['--version']).status,
0,
);
assert.equal(
run(path.join(bundle, 'codex-resources/zsh/bin/zsh'), ['--version']).status,
0,
);
// 使用正式 AGC 查找/校验入口,而非只证明 sidecar 可以独立执行。
const status = run(main, ['--config-dir', config, '--llm-status']);
const statusText = `${status.stdout}\n${status.stderr}`;
assert.ok(!statusText.includes('Codex CLI 未安装'), statusText);
assert.ok(
statusText.includes('authentication-required'),
'隔离账号应仅被登录门禁拒绝',
);
await handshake(executable);
// 临时复制品缺少辅助程序时,正式入口必须拒绝内置程序;PATH 无全局 Codex 可兜底。
fs.renameSync(
path.join(bundle, 'bin/codex-code-mode-host'),
path.join(root, 'saved-code-mode-host'),
);
const broken = run(main, ['--config-dir', config, '--llm-status']);
assert.notEqual(broken.status, 0);
assert.match(`${broken.stdout}\n${broken.stderr}`, /Codex CLI 未安装/);
console.log(
`PASS (${architecture}, 副本=${copiedWith}): 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝`,
);
console.log(
'未验证:GUI、真实登录/Provider 对话、Cocos macOS 原生桥接;插件 Node 仍为外部前提',
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}