Files
Genarrative/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs
T
suzmii 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
修复AGC随包资源staging边界
修复 --no-bundle 构建前的资源准备接线。

拒绝插件随包目录中的未声明文件并同步Rust校验。

增加staging替换失败后的旧资源恢复与回归测试。
2026-09-30 19:12:30 +08:00

1160 lines
36 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 { 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();
}
});