d2d78fa3d4
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m34s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 2m3s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / Repository checks (pull_request) Failing after 25s
Project CI / Native shell tests (pull_request) Failing after 1m44s
Project CI / Frontend tests (pull_request) Successful in 2m12s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m3s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m30s
修复 --no-bundle 构建前的资源准备接线。 拒绝插件随包目录中的未声明文件并同步Rust校验。 增加staging替换失败后的旧资源恢复与回归测试。
1160 lines
36 KiB
JavaScript
1160 lines
36 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import { createHash } from 'node:crypto';
|
||
import fs from 'node:fs';
|
||
import { syncBuiltinESMExports } from 'node:module';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { test } from 'node:test';
|
||
|
||
import {
|
||
DECLARATION_PATH,
|
||
findCodexSource,
|
||
pluginDirectories,
|
||
prepareBundledResources,
|
||
readDeclaration,
|
||
resolveHostTarget,
|
||
stagingUnit,
|
||
} from './prepare-bundled-resources.mjs';
|
||
|
||
const WINDOWS_TARGET = 'x86_64-pc-windows-msvc';
|
||
const MAC_TARGET = 'aarch64-apple-darwin';
|
||
|
||
function sha256File(file) {
|
||
return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||
}
|
||
|
||
/// 造一个最小工作区:app(含 node_modules 上游包)、repo(含 plugins 工作区)、lockfile。
|
||
/// 造出声明里所有「已准备」产物:真实构建要 Windows 工具链,用例只需要文件在位。
|
||
function writePrepareArtifacts(root, declaration) {
|
||
const created = [];
|
||
for (const step of declaration.plugins.prepareSteps ?? []) {
|
||
for (const relative of step.requiredOutputs) {
|
||
const file = path.join(root, relative);
|
||
if (fs.existsSync(file)) {
|
||
continue;
|
||
}
|
||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||
fs.writeFileSync(file, `prepared ${relative}\n`);
|
||
created.push(relative);
|
||
}
|
||
}
|
||
return created;
|
||
}
|
||
|
||
/// 记录调用的假执行器:默认把声明的必需产物造出来。
|
||
function fakeRunCommand({ created = [], failOn = null } = {}) {
|
||
return (invocation) => {
|
||
created.push(`${invocation.program} ${(invocation.args ?? []).join(' ')}`);
|
||
if (failOn && invocation.program === failOn) {
|
||
throw new Error('fake run failure');
|
||
}
|
||
};
|
||
}
|
||
|
||
function buildFixture({
|
||
targets = [WINDOWS_TARGET],
|
||
plugins = true,
|
||
claudeAgent = true,
|
||
} = {}) {
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-resources-'));
|
||
const appRoot = path.join(root, 'app');
|
||
const repoRoot = path.join(root, 'repo');
|
||
const destinationRoot = path.join(appRoot, 'src-tauri');
|
||
const declaration = readDeclaration(DECLARATION_PATH);
|
||
const lockfile = { packages: {} };
|
||
|
||
for (const target of targets) {
|
||
const layout = declaration.codex.targets.find(
|
||
(entry) => entry.target === target,
|
||
);
|
||
assert.ok(layout, `声明缺少目标 ${target}`);
|
||
const vendor = path.join(
|
||
appRoot,
|
||
`node_modules/@openai/codex-${layout.platform}/vendor/${target}`,
|
||
);
|
||
for (const relative of layout.files) {
|
||
const file = path.join(vendor, relative);
|
||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||
fs.writeFileSync(
|
||
file,
|
||
relative === declaration.codex.packageMetadataFileName
|
||
? `${JSON.stringify(
|
||
{
|
||
layoutVersion: declaration.codex.packageMetadata.layoutVersion,
|
||
version: declaration.codex.version,
|
||
target,
|
||
entrypoint: layout.executable,
|
||
resourcesDir: declaration.codex.packageMetadata.resourcesDir,
|
||
pathDir: declaration.codex.packageMetadata.pathDir,
|
||
},
|
||
null,
|
||
2,
|
||
)}\n`
|
||
: `component ${target} ${relative}\n`,
|
||
);
|
||
if (relative === layout.executable) {
|
||
fs.chmodSync(file, 0o755);
|
||
}
|
||
}
|
||
lockfile.packages[`node_modules/@openai/codex-${layout.platform}`] = {
|
||
resolved: `https://registry.npmjs.org/@openai/codex-${layout.platform}/-/${layout.platform}.tgz`,
|
||
integrity: `sha512-${target}`,
|
||
};
|
||
}
|
||
|
||
fs.mkdirSync(path.join(destinationRoot, 'resources/codex'), {
|
||
recursive: true,
|
||
});
|
||
for (const entry of declaration.codex.noticeSources) {
|
||
if (entry.preserve) {
|
||
continue;
|
||
}
|
||
const file = path.join(destinationRoot, entry.source);
|
||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||
fs.writeFileSync(file, 'mac codex notice\n');
|
||
}
|
||
if (targets.includes(WINDOWS_TARGET)) {
|
||
const tracked = path.join(
|
||
destinationRoot,
|
||
'resources/codex/win-x64/NOTICE.md',
|
||
);
|
||
fs.mkdirSync(path.dirname(tracked), { recursive: true });
|
||
fs.writeFileSync(tracked, 'windows codex notice\n');
|
||
}
|
||
|
||
if (plugins) {
|
||
const pluginRoot = path.join(repoRoot, 'plugins/agc-demo-editor');
|
||
fs.mkdirSync(path.join(pluginRoot, 'src'), { recursive: true });
|
||
fs.mkdirSync(path.join(pluginRoot, 'panels'), { recursive: true });
|
||
fs.mkdirSync(path.join(pluginRoot, 'target'), { recursive: true });
|
||
fs.mkdirSync(path.join(pluginRoot, '.git'), { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(pluginRoot, 'plugin.json'),
|
||
'{"name":"agc-demo-editor"}\n',
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(pluginRoot, 'src/entry.mjs'),
|
||
'export const entry = 1;\n',
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(pluginRoot, 'panels/panel.html'),
|
||
'<html></html>\n',
|
||
);
|
||
fs.writeFileSync(path.join(pluginRoot, 'panels/panel.test.mjs'), 'test\n');
|
||
fs.writeFileSync(path.join(pluginRoot, 'target/junk.rs'), 'junk\n');
|
||
fs.writeFileSync(path.join(pluginRoot, '.git/HEAD'), 'ref\n');
|
||
fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n');
|
||
|
||
for (const name of [
|
||
'agc-cocos-editor',
|
||
'agc-unity-editor',
|
||
'agc-godot-editor',
|
||
]) {
|
||
const root = path.join(repoRoot, 'plugins', name);
|
||
fs.mkdirSync(path.join(root, 'src'), {
|
||
recursive: true,
|
||
});
|
||
fs.writeFileSync(path.join(root, 'plugin.json'), `{"name":"${name}"}\n`);
|
||
fs.writeFileSync(
|
||
path.join(root, 'src/entry.mjs'),
|
||
`export const ${name} = 1;\n`,
|
||
);
|
||
}
|
||
const cocosRoot = path.join(repoRoot, 'plugins/agc-cocos-editor');
|
||
fs.mkdirSync(path.join(cocosRoot, 'src'), { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(cocosRoot, 'plugin.json'),
|
||
'{"name":"agc-cocos-editor"}\n',
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(cocosRoot, 'src/entry.mjs'),
|
||
'export const cocos = 1;\n',
|
||
);
|
||
}
|
||
|
||
if (claudeAgent) {
|
||
const declared = declaration.claudeAgent;
|
||
const entry = path.join(appRoot, declared.entryRelativePath);
|
||
fs.mkdirSync(path.dirname(entry), { recursive: true });
|
||
fs.writeFileSync(entry, 'console.log("sidecar");\n');
|
||
const scope = declared.sdkPackageName.split('/')[0];
|
||
const sdkRoot = path.join(
|
||
appRoot,
|
||
declared.nodeModulesDirectory,
|
||
declared.sdkPackageName,
|
||
);
|
||
fs.mkdirSync(path.join(sdkRoot, 'src'), { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(sdkRoot, declared.sdkEntryFileName),
|
||
'export const sdk = 1;\n',
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(sdkRoot, 'src/runner.mjs'),
|
||
'export const run = 1;\n',
|
||
);
|
||
// 上游包里不该随包的内容:跳过规则必须把它们挡在 staging 之外。
|
||
fs.writeFileSync(path.join(sdkRoot, '.env'), 'secret\n');
|
||
fs.writeFileSync(path.join(sdkRoot, 'src/runner.test.mjs'), 'test\n');
|
||
fs.writeFileSync(
|
||
path.join(sdkRoot, declared.sdkPackageMetadataFileName),
|
||
`${JSON.stringify({ version: declared.version })}\n`,
|
||
);
|
||
for (const target of targets) {
|
||
const layout = declared.targets.find((item) => item.target === target);
|
||
assert.ok(layout, `声明缺少目标 ${target} 的 Claude Agent SDK 布局`);
|
||
const runtimeRoot = path.join(
|
||
appRoot,
|
||
declared.nodeModulesDirectory,
|
||
scope,
|
||
layout.runtimePackage,
|
||
);
|
||
fs.mkdirSync(runtimeRoot, { recursive: true });
|
||
const runtimeFile = path.join(runtimeRoot, layout.runtimeFileName);
|
||
fs.writeFileSync(runtimeFile, `runtime ${layout.runtimePackage}\n`);
|
||
fs.chmodSync(runtimeFile, 0o755);
|
||
fs.writeFileSync(
|
||
path.join(runtimeRoot, declared.sdkPackageMetadataFileName),
|
||
`${JSON.stringify({ version: declared.version })}\n`,
|
||
);
|
||
}
|
||
}
|
||
|
||
writePrepareArtifacts(repoRoot, declaration);
|
||
const cocosDll = path.join(
|
||
destinationRoot,
|
||
'target',
|
||
WINDOWS_TARGET,
|
||
'debug',
|
||
'deps',
|
||
'cocos_editor_bridge.dll',
|
||
);
|
||
fs.mkdirSync(path.dirname(cocosDll), { recursive: true });
|
||
fs.writeFileSync(cocosDll, 'cocos bridge payload\n');
|
||
|
||
const lockfilePath = path.join(root, 'package-lock.json');
|
||
fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2));
|
||
return {
|
||
root,
|
||
appRoot,
|
||
repoRoot,
|
||
destinationRoot,
|
||
lockfilePath,
|
||
recordPath: path.join(root, 'record.json'),
|
||
declaration,
|
||
cleanup: () => fs.rmSync(root, { recursive: true, force: true }),
|
||
};
|
||
}
|
||
|
||
function snapshot(directory) {
|
||
const entries = [];
|
||
const stack = [['', directory]];
|
||
while (stack.length > 0) {
|
||
const [prefix, current] = stack.pop();
|
||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||
const full = path.join(current, entry.name);
|
||
if (entry.isDirectory()) {
|
||
stack.push([relative, full]);
|
||
} else {
|
||
const info = fs.statSync(full);
|
||
entries.push({
|
||
relative,
|
||
size: info.size,
|
||
mtimeMs: info.mtimeMs,
|
||
mode: info.mode & 0o777,
|
||
sha256: sha256File(full),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return entries.sort((left, right) =>
|
||
left.relative.localeCompare(right.relative),
|
||
);
|
||
}
|
||
|
||
function prepare(fixture, overrides = {}) {
|
||
const runner = overrides.runCommand ?? fakeRunCommand();
|
||
return prepareBundledResources({
|
||
target: WINDOWS_TARGET,
|
||
destinationRoot: fixture.destinationRoot,
|
||
declarationPath: DECLARATION_PATH,
|
||
recordPath: fixture.recordPath,
|
||
lockfilePath: fixture.lockfilePath,
|
||
repoRoot: fixture.repoRoot,
|
||
appRoot: fixture.appRoot,
|
||
runCommand: runner,
|
||
...overrides,
|
||
});
|
||
}
|
||
|
||
/// 替换失败注入:让「staging → 目标目录」的那一次 rename 抛错。
|
||
/// 走 node:fs 的 ESM 活绑定(syncBuiltinESMExports 同步),实现里不得为测试开后门;
|
||
/// body 是同步的,注入窗口内不会有别的调用跑进来。
|
||
function withRenameFailure(predicate, body) {
|
||
const original = fs.renameSync;
|
||
let injected = false;
|
||
fs.renameSync = (from, to) => {
|
||
if (
|
||
!injected &&
|
||
predicate(path.resolve(String(from)), path.resolve(String(to)))
|
||
) {
|
||
injected = true;
|
||
throw Object.assign(new Error('注入的替换失败'), { code: 'EPERM' });
|
||
}
|
||
return original.call(fs, from, to);
|
||
};
|
||
syncBuiltinESMExports();
|
||
try {
|
||
return body();
|
||
} finally {
|
||
fs.renameSync = original;
|
||
syncBuiltinESMExports();
|
||
}
|
||
}
|
||
|
||
/// 把「必须失败」的调用收敛成断言:返回错误对象,没抛错即用例失败。
|
||
function expectThrow(body) {
|
||
try {
|
||
body();
|
||
} catch (error) {
|
||
return error;
|
||
}
|
||
throw new Error('预期抛错但调用成功了');
|
||
}
|
||
|
||
test('stages declared codex components with manifest and preserved notice', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
const summaries = prepare(fixture);
|
||
assert.match(summaries[0], /codex x86_64-pc-windows-msvc 重新生成/);
|
||
|
||
const declaration = fixture.declaration;
|
||
const layout = declaration.codex.targets.find(
|
||
(entry) => entry.target === WINDOWS_TARGET,
|
||
);
|
||
const unit = path.join(
|
||
fixture.destinationRoot,
|
||
'resources/codex',
|
||
layout.directory,
|
||
);
|
||
for (const relative of layout.files) {
|
||
assert.ok(
|
||
fs.existsSync(path.join(unit, relative)),
|
||
`缺少组件 ${relative}`,
|
||
);
|
||
}
|
||
assert.equal(
|
||
fs.readFileSync(path.join(unit, 'NOTICE.md'), 'utf8'),
|
||
'windows codex notice\n',
|
||
'受版本控制的第三方声明必须原地保留',
|
||
);
|
||
const manifest = JSON.parse(
|
||
fs.readFileSync(path.join(unit, 'manifest.json'), 'utf8'),
|
||
);
|
||
assert.deepEqual(Object.keys(manifest), [
|
||
'files',
|
||
'platform',
|
||
'schemaVersion',
|
||
'version',
|
||
]);
|
||
assert.equal(manifest.platform, layout.platform);
|
||
assert.equal(manifest.schemaVersion, declaration.codex.manifestSchema);
|
||
assert.equal(
|
||
manifest.version,
|
||
`${declaration.codex.cliVersionPrefix}${declaration.codex.version}`,
|
||
);
|
||
assert.deepEqual(
|
||
Object.keys(manifest.files).sort(),
|
||
[...layout.files].sort(),
|
||
'清单文件集合必须等于组件白名单',
|
||
);
|
||
for (const relative of layout.files) {
|
||
assert.equal(
|
||
manifest.files[relative],
|
||
sha256File(path.join(unit, relative)),
|
||
);
|
||
}
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('second run is a no-op: identical content and timestamps', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
prepare(fixture);
|
||
const unit = path.join(
|
||
fixture.destinationRoot,
|
||
'resources/codex',
|
||
'win-x64',
|
||
);
|
||
const plugins = path.join(fixture.destinationRoot, 'resources/plugins');
|
||
const claudeAgent = path.join(
|
||
fixture.destinationRoot,
|
||
fixture.declaration.claudeAgent.resourceDirectory,
|
||
);
|
||
const before = {
|
||
codex: snapshot(unit),
|
||
plugins: snapshot(plugins),
|
||
claudeAgent: snapshot(claudeAgent),
|
||
};
|
||
const summaries = prepare(fixture);
|
||
assert.match(summaries[0], /命中缓存/);
|
||
assert.match(summaries[1], /命中缓存/);
|
||
assert.match(summaries[2], /命中缓存/);
|
||
assert.deepEqual(
|
||
snapshot(unit),
|
||
before.codex,
|
||
'codex 产物内容与时间戳必须不变',
|
||
);
|
||
assert.deepEqual(
|
||
snapshot(plugins),
|
||
before.plugins,
|
||
'插件产物内容与时间戳必须不变',
|
||
);
|
||
assert.deepEqual(
|
||
snapshot(claudeAgent),
|
||
before.claudeAgent,
|
||
'Claude Agent SDK sidecar 产物内容与时间戳必须不变',
|
||
);
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('stages the macOS universal group with both architectures', () => {
|
||
const fixture = buildFixture({
|
||
targets: [MAC_TARGET, 'x86_64-apple-darwin'],
|
||
});
|
||
try {
|
||
const summaries = prepare(fixture, { target: MAC_TARGET });
|
||
assert.match(summaries[0], /mac-native/);
|
||
const unit = path.join(
|
||
fixture.destinationRoot,
|
||
'resources/codex/mac-native',
|
||
);
|
||
for (const directory of ['darwin-arm64', 'darwin-x64']) {
|
||
for (const file of ['bin/codex', 'manifest.json', 'NOTICE.md']) {
|
||
assert.ok(
|
||
fs.existsSync(path.join(unit, directory, file)),
|
||
`缺少 ${directory}/${file}`,
|
||
);
|
||
}
|
||
assert.equal(
|
||
fs.readFileSync(path.join(unit, directory, 'NOTICE.md'), 'utf8'),
|
||
'mac codex notice\n',
|
||
);
|
||
}
|
||
assert.deepEqual(fs.readdirSync(unit).sort(), [
|
||
'darwin-arm64',
|
||
'darwin-x64',
|
||
]);
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('copies only whitelisted plugin subdirectories', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
prepare(fixture);
|
||
const staged = path.join(
|
||
fixture.destinationRoot,
|
||
'resources/plugins/agc-demo-editor',
|
||
);
|
||
assert.ok(fs.existsSync(path.join(staged, 'plugin.json')));
|
||
assert.ok(fs.existsSync(path.join(staged, 'src/entry.mjs')));
|
||
assert.ok(fs.existsSync(path.join(staged, 'panels/panel.html')));
|
||
assert.ok(
|
||
!fs.existsSync(path.join(staged, 'panels/panel.test.mjs')),
|
||
'测试文件不随包',
|
||
);
|
||
assert.ok(
|
||
!fs.existsSync(path.join(staged, 'target')),
|
||
'构建产物目录不随包',
|
||
);
|
||
assert.ok(!fs.existsSync(path.join(staged, '.git')), '隐藏目录不随包');
|
||
assert.ok(!fs.existsSync(path.join(staged, '.env')), '隐藏文件不随包');
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('fails closed when the upstream package metadata drifts from the declaration', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
const declaration = fixture.declaration;
|
||
const layout = declaration.codex.targets.find(
|
||
(entry) => entry.target === WINDOWS_TARGET,
|
||
);
|
||
const metadataFile = path.join(
|
||
fixture.appRoot,
|
||
`node_modules/@openai/codex-${layout.platform}/vendor/${WINDOWS_TARGET}/codex-package.json`,
|
||
);
|
||
const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf8'));
|
||
assert.equal(metadata.version, declaration.codex.version);
|
||
for (const [key, value] of [
|
||
['version', '0.0.0'],
|
||
['layoutVersion', 2],
|
||
['entrypoint', 'bin/other.exe'],
|
||
['resourcesDir', '../private'],
|
||
]) {
|
||
fs.writeFileSync(
|
||
metadataFile,
|
||
`${JSON.stringify({ ...metadata, [key]: value }, null, 2)}\n`,
|
||
);
|
||
assert.throws(
|
||
() => prepare(fixture),
|
||
/上游包元数据与声明不一致/,
|
||
`${key} 漂移必须被拒绝`,
|
||
);
|
||
}
|
||
assert.ok(
|
||
!fs.existsSync(
|
||
path.join(
|
||
fixture.destinationRoot,
|
||
'resources/codex/win-x64/manifest.json',
|
||
),
|
||
),
|
||
'拒绝时不得留下产物',
|
||
);
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('fails closed when the upstream package is missing', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
fs.rmSync(path.join(fixture.appRoot, 'node_modules'), {
|
||
recursive: true,
|
||
force: true,
|
||
});
|
||
assert.throws(() => prepare(fixture), /npm ci/);
|
||
assert.ok(
|
||
!fs.existsSync(
|
||
path.join(
|
||
fixture.destinationRoot,
|
||
'resources/codex/win-x64/manifest.json',
|
||
),
|
||
),
|
||
'失败时不得留下半成品清单',
|
||
);
|
||
assert.ok(
|
||
!fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')),
|
||
);
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('fails closed for unsupported targets', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
assert.throws(
|
||
() => prepare(fixture, { target: 'x86_64-unknown-linux-gnu' }),
|
||
/声明不含目标/,
|
||
);
|
||
assert.throws(() => resolveHostTarget('linux', 'x64'), /不支持的目标平台/);
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('fails closed when the destination is owned by something else', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64');
|
||
fs.writeFileSync(path.join(unit, 'foreign.bin'), 'foreign\n');
|
||
assert.throws(() => prepare(fixture), /被非本工具内容占用/);
|
||
|
||
const plugins = path.join(fixture.destinationRoot, 'resources/plugins');
|
||
fs.rmSync(path.join(unit, 'foreign.bin'), { force: true });
|
||
fs.mkdirSync(path.join(plugins, 'someone-elses-plugin'), {
|
||
recursive: true,
|
||
});
|
||
assert.throws(() => prepare(fixture), /插件随包目录被非本工具内容占用/);
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('stages the Claude Agent SDK sidecar with the platform runtime', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
const summaries = prepare(fixture);
|
||
const declared = fixture.declaration.claudeAgent;
|
||
assert.match(summaries[2], /claude-agent x86_64-pc-windows-msvc 重新生成/);
|
||
const unit = path.join(fixture.destinationRoot, declared.resourceDirectory);
|
||
const layout = declared.targets.find(
|
||
(entry) => entry.target === WINDOWS_TARGET,
|
||
);
|
||
const scope = declared.sdkPackageName.split('/')[0];
|
||
const stagedSdk = path.join(
|
||
unit,
|
||
declared.nodeModulesDirectory,
|
||
declared.sdkPackageName,
|
||
);
|
||
for (const file of [
|
||
path.join(unit, declared.entryFileName),
|
||
path.join(stagedSdk, declared.sdkEntryFileName),
|
||
path.join(stagedSdk, 'src/runner.mjs'),
|
||
path.join(
|
||
unit,
|
||
declared.nodeModulesDirectory,
|
||
scope,
|
||
layout.runtimePackage,
|
||
layout.runtimeFileName,
|
||
),
|
||
]) {
|
||
assert.ok(fs.existsSync(file), `缺少 ${file}`);
|
||
}
|
||
// 跳过规则:上游包里的私密文件与测试文件不得随包。
|
||
assert.ok(!fs.existsSync(path.join(stagedSdk, '.env')));
|
||
assert.ok(!fs.existsSync(path.join(stagedSdk, 'src/runner.test.mjs')));
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('fails closed when the Claude Agent SDK upstream package is missing', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
fs.rmSync(path.join(fixture.appRoot, 'node_modules/@anthropic-ai'), {
|
||
recursive: true,
|
||
force: true,
|
||
});
|
||
assert.throws(() => prepare(fixture), /Claude Agent SDK 上游包缺失/);
|
||
assert.ok(
|
||
!fs.existsSync(
|
||
path.join(
|
||
fixture.destinationRoot,
|
||
fixture.declaration.claudeAgent.resourceDirectory,
|
||
),
|
||
),
|
||
'失败关闭时不得留下半成品',
|
||
);
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('fails closed when the upstream Claude Agent SDK version drifts', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
const declared = fixture.declaration.claudeAgent;
|
||
const metadataFile = path.join(
|
||
fixture.appRoot,
|
||
declared.nodeModulesDirectory,
|
||
declared.sdkPackageName,
|
||
declared.sdkPackageMetadataFileName,
|
||
);
|
||
fs.writeFileSync(metadataFile, `${JSON.stringify({ version: '0.0.0' })}\n`);
|
||
assert.throws(() => prepare(fixture), /版本与声明不一致/);
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('fails closed when the Claude Agent SDK destination is owned by something else', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
const unit = path.join(
|
||
fixture.destinationRoot,
|
||
fixture.declaration.claudeAgent.resourceDirectory,
|
||
);
|
||
fs.mkdirSync(unit, { recursive: true });
|
||
fs.writeFileSync(path.join(unit, 'foreign.bin'), 'foreign\n');
|
||
assert.throws(() => prepare(fixture), /sidecar 随包目录被非本工具内容占用/);
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('dry run writes nothing', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
const summaries = prepare(fixture, {
|
||
dryRun: true,
|
||
features: new Set([
|
||
'unity-editor-execute',
|
||
'godot-editor-execute',
|
||
'cocos-editor-injection',
|
||
]),
|
||
});
|
||
assert.match(summaries[0], /需要重新生成(dry-run 未写入)/);
|
||
const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64');
|
||
assert.deepEqual(
|
||
fs.readdirSync(unit),
|
||
['NOTICE.md'],
|
||
'dry-run 不得写入任何组件或清单',
|
||
);
|
||
assert.ok(
|
||
!fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')),
|
||
);
|
||
assert.ok(
|
||
!fs.existsSync(
|
||
path.join(
|
||
fixture.destinationRoot,
|
||
fixture.declaration.claudeAgent.resourceDirectory,
|
||
),
|
||
),
|
||
);
|
||
assert.ok(!fs.existsSync(fixture.recordPath));
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('declaration drives source lookup and staging units', () => {
|
||
const declaration = readDeclaration(DECLARATION_PATH);
|
||
const windows = stagingUnit(declaration, WINDOWS_TARGET);
|
||
assert.equal(windows.directory, 'win-x64');
|
||
assert.deepEqual(
|
||
windows.targets.map((member) => member.target),
|
||
[WINDOWS_TARGET],
|
||
);
|
||
const mac = stagingUnit(declaration, MAC_TARGET);
|
||
assert.equal(mac.directory, 'mac-native');
|
||
assert.deepEqual(
|
||
mac.targets.map((member) => member.target),
|
||
['aarch64-apple-darwin', 'x86_64-apple-darwin'],
|
||
);
|
||
|
||
const fixture = buildFixture();
|
||
try {
|
||
const source = findCodexSource(declaration, WINDOWS_TARGET, {
|
||
app: fixture.appRoot,
|
||
repo: fixture.repoRoot,
|
||
});
|
||
assert.match(
|
||
source,
|
||
/codex-win32-x64[\\/]vendor[\\/]x86_64-pc-windows-msvc$/u,
|
||
);
|
||
assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 4);
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('runs declared prepare steps once and copies their artifacts into the staged tree', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
const created = [];
|
||
const summaries = prepare(fixture, {
|
||
runCommand: fakeRunCommand({ created }),
|
||
features: new Set([
|
||
'unity-editor-execute',
|
||
'godot-editor-execute',
|
||
'cocos-editor-injection',
|
||
]),
|
||
});
|
||
assert.match(summaries[0], /命中缓存|重新生成/);
|
||
assert.ok(
|
||
created.some(
|
||
(entry) =>
|
||
entry.startsWith('powershell.exe') && entry.includes('build.ps1'),
|
||
),
|
||
'必须执行声明的 powershell 准备步骤',
|
||
);
|
||
assert.ok(
|
||
created.some(
|
||
(entry) =>
|
||
entry.startsWith('cargo build') && entry.includes('--target'),
|
||
),
|
||
'必须执行声明的 cargo 准备步骤',
|
||
);
|
||
const stagedPayload = path.join(
|
||
fixture.destinationRoot,
|
||
'resources/plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll',
|
||
);
|
||
assert.ok(fs.existsSync(stagedPayload), 'native payload 必须进随包目录');
|
||
assert.ok(
|
||
fs.existsSync(
|
||
path.join(
|
||
fixture.repoRoot,
|
||
'plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll',
|
||
),
|
||
),
|
||
'native payload 必须写回插件工作区(唯一真源)',
|
||
);
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('skips prepare steps whose fingerprint is unchanged', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
const first = [];
|
||
prepare(fixture, { runCommand: fakeRunCommand({ created: first }) });
|
||
assert.ok(first.length > 0, '首次必须执行准备步骤');
|
||
const second = [];
|
||
prepare(fixture, { runCommand: fakeRunCommand({ created: second }) });
|
||
const unityRuns = second.filter(
|
||
(entry) =>
|
||
entry.startsWith('powershell.exe') &&
|
||
entry.includes('agc-unity-editor'),
|
||
);
|
||
assert.deepEqual(
|
||
unityRuns,
|
||
[],
|
||
'指纹一致时不应再跑声明了指纹的 Unity 准备步骤',
|
||
);
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('fails closed when a prepare step does not produce its declared outputs', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
fs.rmSync(path.join(fixture.repoRoot, 'plugins/agc-unity-editor'), {
|
||
recursive: true,
|
||
force: true,
|
||
});
|
||
assert.throws(
|
||
() => prepare(fixture, { runCommand: fakeRunCommand() }),
|
||
/未产出必需文件/,
|
||
);
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('delivers editor branch artifacts declared as prepared or library staging', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
const features = new Set([
|
||
'unity-editor-execute',
|
||
'godot-editor-execute',
|
||
'cocos-editor-injection',
|
||
]);
|
||
prepare(fixture, { features });
|
||
const staged = (relative) =>
|
||
path.join(fixture.destinationRoot, 'resources/plugins', relative);
|
||
|
||
// Unity:prepared 子目录整体复制
|
||
assert.ok(
|
||
fs.existsSync(
|
||
staged('agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe'),
|
||
),
|
||
'Unity helper 必须随包',
|
||
);
|
||
// Godot:libraryStaging 声明文件逐个复制
|
||
assert.ok(
|
||
fs.existsSync(
|
||
staged(
|
||
'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll',
|
||
),
|
||
),
|
||
'Godot 扩展必须随包',
|
||
);
|
||
assert.ok(
|
||
fs.existsSync(
|
||
staged('agc-godot-editor/native/gdextension/vendor/provenance.json'),
|
||
),
|
||
'Godot 随包清单文件必须随包',
|
||
);
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
/// 全量 Windows 编辑器 feature:prepared / libraryStaging / nativePayload 三条交付路径全开。
|
||
const ALL_WINDOWS_FEATURES = new Set([
|
||
'unity-editor-execute',
|
||
'godot-editor-execute',
|
||
'cocos-editor-injection',
|
||
]);
|
||
|
||
function pluginStaged(fixture, relative) {
|
||
return path.join(
|
||
fixture.destinationRoot,
|
||
fixture.declaration.plugins.destinationDirectory,
|
||
relative,
|
||
);
|
||
}
|
||
|
||
test('keeps the previous codex resources when the replacement fails', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
prepare(fixture);
|
||
const declaration = fixture.declaration;
|
||
const layout = declaration.codex.targets.find(
|
||
(entry) => entry.target === WINDOWS_TARGET,
|
||
);
|
||
const unit = path.join(
|
||
fixture.destinationRoot,
|
||
declaration.codex.resourceDirectory,
|
||
layout.directory,
|
||
);
|
||
const before = snapshot(unit);
|
||
const component = layout.files.find((relative) =>
|
||
relative.includes('code-mode-host'),
|
||
);
|
||
const vendor = path.join(
|
||
fixture.appRoot,
|
||
`node_modules/@openai/codex-${layout.platform}/vendor/${WINDOWS_TARGET}`,
|
||
);
|
||
// 上游组件变了:这一次必然走「staging → 目标」的替换,注入的失败正好落在替换上。
|
||
fs.writeFileSync(path.join(vendor, component), 'changed component\n');
|
||
// 锁定包未变化时 Codex 走缓存;移除记录强制重新读取上游内容。
|
||
fs.rmSync(fixture.recordPath);
|
||
|
||
const error = expectThrow(() =>
|
||
withRenameFailure(
|
||
(from, to) => to === path.resolve(unit),
|
||
() => prepare(fixture),
|
||
),
|
||
);
|
||
assert.match(error.message, /替换 .*win-x64.* 失败/u);
|
||
assert.match(error.message, /旧资源已恢复/u);
|
||
assert.deepEqual(
|
||
snapshot(unit),
|
||
before,
|
||
'替换失败必须把旧资源原样恢复:内容、尺寸、时间戳与可执行位都不许变',
|
||
);
|
||
assert.equal(
|
||
fs.readFileSync(
|
||
path.join(unit, declaration.codex.noticeFileName),
|
||
'utf8',
|
||
),
|
||
'windows codex notice\n',
|
||
'受版本控制的第三方声明必须还在原位',
|
||
);
|
||
const stagingPath = /staging 保留在 ([^;]+);/u.exec(error.message)?.[1];
|
||
assert.ok(stagingPath, `报错必须给出 staging 位置:${error.message}`);
|
||
// 目标目录旁边只允许剩「旧资源」和「留给人工检查的新 staging」;backup 必须已经归位。
|
||
const siblings = fs.readdirSync(path.dirname(unit));
|
||
assert.ok(siblings.includes(path.basename(unit)));
|
||
assert.ok(siblings.includes(path.basename(stagingPath)));
|
||
assert.ok(
|
||
siblings.every((entry) => !entry.includes('-backup-')),
|
||
'恢复成功后不得留下 backup 目录',
|
||
);
|
||
assert.equal(
|
||
fs.readFileSync(path.join(stagingPath, component), 'utf8'),
|
||
'changed component\n',
|
||
'没有落盘的新产物必须留在 staging 里供人工检查',
|
||
);
|
||
|
||
// 注入消失后重试必须成功:旧资源完整 → 替换重新做一遍 → 落盘的是上游新内容。
|
||
const summaries = prepare(fixture);
|
||
assert.match(summaries[0], /重新生成/u);
|
||
assert.equal(
|
||
fs.readFileSync(path.join(unit, component), 'utf8'),
|
||
'changed component\n',
|
||
);
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('keeps the previous plugin resources when the replacement fails', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
prepare(fixture);
|
||
const destination = path.join(
|
||
fixture.destinationRoot,
|
||
fixture.declaration.plugins.destinationDirectory,
|
||
);
|
||
// 未声明的额外文件让缓存判定必然漂移:这次一定会走完整替换。
|
||
fs.writeFileSync(
|
||
path.join(destination, 'agc-demo-editor/src/rogue.mjs'),
|
||
'rogue\n',
|
||
);
|
||
const before = snapshot(destination);
|
||
|
||
const error = expectThrow(() =>
|
||
withRenameFailure(
|
||
(from, to) => to === path.resolve(destination),
|
||
() => prepare(fixture),
|
||
),
|
||
);
|
||
assert.match(error.message, /替换 .*plugins.* 失败/u);
|
||
assert.match(error.message, /旧资源已恢复/u);
|
||
assert.deepEqual(
|
||
snapshot(destination),
|
||
before,
|
||
'替换失败时旧插件资源(含尚未清理的额外文件)必须逐字节保留',
|
||
);
|
||
|
||
prepare(fixture);
|
||
assert.ok(
|
||
!fs.existsSync(path.join(destination, 'agc-demo-editor/src/rogue.mjs')),
|
||
'注入消失后重试必须成功并清掉额外文件',
|
||
);
|
||
assert.ok(
|
||
fs.existsSync(
|
||
path.join(destination, 'agc-demo-editor/panels/panel.html'),
|
||
),
|
||
'重试后声明的随包内容必须齐全',
|
||
);
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('clears undeclared and hidden entries left in the staged plugin tree', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
prepare(fixture);
|
||
// 三种残留:额外目录、隐藏目录、声明的源码目录里的隐藏文件。
|
||
for (const relative of [
|
||
'agc-demo-editor/extra/rogue.txt',
|
||
'agc-demo-editor/.cache/tmp.bin',
|
||
'agc-demo-editor/src/.env',
|
||
]) {
|
||
const file = pluginStaged(fixture, relative);
|
||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||
fs.writeFileSync(file, 'residue\n');
|
||
}
|
||
|
||
const summaries = prepare(fixture);
|
||
assert.match(summaries[1], /plugins 重新生成/u);
|
||
for (const relative of [
|
||
'agc-demo-editor/extra',
|
||
'agc-demo-editor/.cache',
|
||
'agc-demo-editor/src/.env',
|
||
]) {
|
||
assert.ok(
|
||
!fs.existsSync(pluginStaged(fixture, relative)),
|
||
`未声明的残留 ${relative} 必须被清掉`,
|
||
);
|
||
}
|
||
for (const relative of [
|
||
'agc-demo-editor/plugin.json',
|
||
'agc-demo-editor/src/entry.mjs',
|
||
'agc-demo-editor/panels/panel.html',
|
||
]) {
|
||
assert.ok(
|
||
fs.existsSync(pluginStaged(fixture, relative)),
|
||
`声明的随包内容 ${relative} 必须还在`,
|
||
);
|
||
}
|
||
assert.ok(
|
||
!fs.existsSync(
|
||
pluginStaged(fixture, 'agc-demo-editor/panels/panel.test.mjs'),
|
||
),
|
||
'跳过规则在重建后依然生效',
|
||
);
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('removes staged plugin files whose source has been deleted', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
prepare(fixture);
|
||
assert.ok(
|
||
fs.existsSync(pluginStaged(fixture, 'agc-demo-editor/panels/panel.html')),
|
||
'前置条件:源码里的文件已经随包',
|
||
);
|
||
fs.rmSync(
|
||
path.join(fixture.repoRoot, 'plugins/agc-demo-editor/panels/panel.html'),
|
||
);
|
||
|
||
const summaries = prepare(fixture);
|
||
assert.match(summaries[1], /plugins 重新生成/u);
|
||
assert.ok(
|
||
!fs.existsSync(
|
||
pluginStaged(fixture, 'agc-demo-editor/panels/panel.html'),
|
||
),
|
||
'源码里已删除的文件不得留在随包目录',
|
||
);
|
||
assert.ok(
|
||
fs.existsSync(pluginStaged(fixture, 'agc-demo-editor/src/entry.mjs')),
|
||
'同一插件的其他声明内容必须还在',
|
||
);
|
||
// 重建后的目录必须自洽:紧接着一次准备应命中缓存而不是反复重建。
|
||
assert.match(prepare(fixture)[1], /命中缓存/u);
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('clears staged prepared payloads when their feature is disabled', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
prepare(fixture, { features: ALL_WINDOWS_FEATURES });
|
||
for (const relative of [
|
||
'agc-cocos-editor/native/payload/cocos-editor-bridge.dll',
|
||
'agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe',
|
||
'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll',
|
||
'agc-godot-editor/native/gdextension/vendor/provenance.json',
|
||
]) {
|
||
assert.ok(
|
||
fs.existsSync(pluginStaged(fixture, relative)),
|
||
`前置条件:feature 全开时 ${relative} 必须随包`,
|
||
);
|
||
}
|
||
|
||
prepare(fixture, { features: new Set() });
|
||
for (const relative of [
|
||
'agc-cocos-editor/native/payload',
|
||
'agc-unity-editor/dotnet',
|
||
'agc-godot-editor/native',
|
||
]) {
|
||
assert.ok(
|
||
!fs.existsSync(pluginStaged(fixture, relative)),
|
||
`feature 关闭后 ${relative} 不得作为残留继续随包`,
|
||
);
|
||
}
|
||
for (const plugin of [
|
||
'agc-cocos-editor',
|
||
'agc-unity-editor',
|
||
'agc-godot-editor',
|
||
]) {
|
||
assert.ok(
|
||
fs.existsSync(pluginStaged(fixture, `${plugin}/plugin.json`)),
|
||
`feature 关闭不得动到 ${plugin} 的声明内容`,
|
||
);
|
||
assert.ok(
|
||
fs.existsSync(pluginStaged(fixture, `${plugin}/src/entry.mjs`)),
|
||
`feature 关闭不得动到 ${plugin} 的源码内容`,
|
||
);
|
||
}
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|
||
|
||
test('treats declared prepared and library-staging artifacts as owned on a second run', () => {
|
||
const fixture = buildFixture();
|
||
try {
|
||
prepare(fixture, { features: ALL_WINDOWS_FEATURES });
|
||
const destination = path.join(
|
||
fixture.destinationRoot,
|
||
fixture.declaration.plugins.destinationDirectory,
|
||
);
|
||
const before = snapshot(destination);
|
||
|
||
const summaries = prepare(fixture, { features: ALL_WINDOWS_FEATURES });
|
||
assert.match(
|
||
summaries[1],
|
||
/plugins 命中缓存(未写入/u,
|
||
'prepared 子目录与 libraryStaging 产物属于本工具,不得被误判成外来内容而反复重建',
|
||
);
|
||
assert.deepEqual(
|
||
snapshot(destination),
|
||
before,
|
||
'命中缓存时一个字节、一个时间戳都不许动',
|
||
);
|
||
for (const relative of [
|
||
'agc-cocos-editor/native/payload/cocos-editor-bridge.dll',
|
||
'agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe',
|
||
'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll',
|
||
'agc-godot-editor/native/gdextension/vendor/provenance.json',
|
||
]) {
|
||
assert.ok(
|
||
fs.existsSync(pluginStaged(fixture, relative)),
|
||
`已声明产物 ${relative} 不得因为缓存判定被删掉`,
|
||
);
|
||
}
|
||
} finally {
|
||
fixture.cleanup();
|
||
}
|
||
});
|