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
并行度参数默认改为8以吃满节点,并在说明里记录其作为rustc jobserver令牌上限的影响 产品名统一从Tauri配置推导,校验脚本从Info.plist读取可执行名,改产品名不再静默失效 隔离smoke改用ditto --clone复制副本,实测整轮6.8秒 Agent工作区守卫改为按目录约定匹配,节点改名后仍成立 签到同时取master以便解析上次发布commit,失败仅降级更新摘要 补充上述行为的回归断言
285 lines
9.4 KiB
JavaScript
285 lines
9.4 KiB
JavaScript
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 });
|
||
}
|