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'), '\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(); } });