修复AGC随包资源staging边界
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
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替换失败后的旧资源恢复与回归测试。
This commit is contained in:
@@ -505,10 +505,11 @@ export function runTauriBuild(
|
||||
const tauriArguments = buildTauriBuildArguments(args, context.target);
|
||||
const { channel, target } = context;
|
||||
const features = resolveEditorFeatures({ argv: args, target });
|
||||
// --no-bundle 只跳过发行运行时资源;应用自身构建仍需先准备随包资源。
|
||||
if (!args.includes('--no-bundle')) {
|
||||
stageRuntime(target);
|
||||
stageBundled(target, { features });
|
||||
}
|
||||
stageBundled(target, { features });
|
||||
const configPath = writeChannelConfigFile(
|
||||
channel,
|
||||
target,
|
||||
|
||||
@@ -1001,6 +1001,7 @@ test('release stages Node before Tauri and injects its resource mapping only for
|
||||
events.push('bundled');
|
||||
},
|
||||
spawn(_binary, args) {
|
||||
events.push('build');
|
||||
const config = JSON.parse(
|
||||
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
|
||||
);
|
||||
@@ -1013,6 +1014,7 @@ test('release stages Node before Tauri and injects its resource mapping only for
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(events, ['bundled', 'build']);
|
||||
assert.throws(
|
||||
() =>
|
||||
runTauriBuild(['--target', windowsTarget], context, {
|
||||
|
||||
@@ -485,6 +485,7 @@ function renderLibraryStaging(entry) {
|
||||
['targets', rustStrings(entry.targets)],
|
||||
['features', rustStrings(entry.features)],
|
||||
['layout', rustString(entry.layout)],
|
||||
['files', rustStrings(entry.files)],
|
||||
],
|
||||
1,
|
||||
);
|
||||
|
||||
@@ -488,8 +488,11 @@ function buildUnitInto(
|
||||
}
|
||||
|
||||
function stageAtomically(unitPath, builder) {
|
||||
const stagingPath = `${unitPath}-staging-${process.pid}-${randomBytes(4).toString('hex')}`;
|
||||
const suffix = `${process.pid}-${randomBytes(4).toString('hex')}`;
|
||||
const stagingPath = `${unitPath}-staging-${suffix}`;
|
||||
const backupPath = `${unitPath}-backup-${suffix}`;
|
||||
rmSync(stagingPath, { recursive: true, force: true });
|
||||
rmSync(backupPath, { recursive: true, force: true });
|
||||
mkdirSync(stagingPath, { recursive: true });
|
||||
try {
|
||||
builder(stagingPath);
|
||||
@@ -497,15 +500,31 @@ function stageAtomically(unitPath, builder) {
|
||||
rmSync(stagingPath, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
rmSync(unitPath, { recursive: true, force: true });
|
||||
|
||||
const hadPrevious = existsSync(unitPath);
|
||||
try {
|
||||
if (hadPrevious) {
|
||||
renameSync(unitPath, backupPath);
|
||||
}
|
||||
renameSync(stagingPath, unitPath);
|
||||
} catch (error) {
|
||||
// 并发调用未做加锁(见技术方案 §4.3):这里只保证失败可读、且不丢已经生成好的 staging。
|
||||
let restored = !hadPrevious;
|
||||
if (hadPrevious && existsSync(backupPath)) {
|
||||
try {
|
||||
if (existsSync(unitPath)) {
|
||||
rmSync(unitPath, { recursive: true, force: true });
|
||||
}
|
||||
renameSync(backupPath, unitPath);
|
||||
restored = true;
|
||||
} catch {
|
||||
restored = false;
|
||||
}
|
||||
}
|
||||
fail(
|
||||
`替换 ${unitPath} 失败(${error.code ?? error.message}):同一资源目录可能正被另一个准备步骤进程写入;staging 已保留在 ${stagingPath},确认没有并发进程后重试`,
|
||||
`替换 ${unitPath} 失败(${error.code ?? error.message}):staging 保留在 ${stagingPath};旧资源${restored ? '已恢复' : '恢复失败,请检查 ' + backupPath},确认没有并发进程后重试`,
|
||||
);
|
||||
}
|
||||
rmSync(backupPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function prepareCodex({
|
||||
@@ -860,6 +879,30 @@ function subdirectoryEnabled(subdirectory, target, features) {
|
||||
return matchesContains && matchesTarget && matchesFeatures;
|
||||
}
|
||||
|
||||
function collectTreeFiles(root, label) {
|
||||
const files = [];
|
||||
const stack = [['', root]];
|
||||
while (stack.length > 0) {
|
||||
const [prefix, directory] = stack.pop();
|
||||
if (!existsSync(directory)) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
fail(`${label}不允许符号链接:${entryPath}`);
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
stack.push([relative, entryPath]);
|
||||
} else if (entry.isFile()) {
|
||||
files.push(relative);
|
||||
}
|
||||
}
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
/// 复制规则由各 section 自带(plugins / claudeAgent),同名语义、同序判定。
|
||||
function skipDirectory(rules, name) {
|
||||
return (
|
||||
@@ -958,7 +1001,7 @@ function pluginSourceFingerprint(declaration, plugins, target, features) {
|
||||
for (const staging of declaration.plugins.libraryStaging ?? []) {
|
||||
if (
|
||||
plugin.name !== staging.plugin ||
|
||||
libraryStagingEnabled(staging, target, features)
|
||||
!libraryStagingEnabled(staging, target, features)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -978,7 +1021,7 @@ function pluginSourceFingerprint(declaration, plugins, target, features) {
|
||||
for (const payload of declaration.plugins.nativePayloads ?? []) {
|
||||
if (
|
||||
plugin.name !== payload.plugin ||
|
||||
nativePayloadEnabled(payload, target, features)
|
||||
!nativePayloadEnabled(payload, target, features)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -1007,8 +1050,11 @@ function pluginTreeMatches(
|
||||
if (!existsSync(destination)) {
|
||||
return false;
|
||||
}
|
||||
const allowedFiles = new Set();
|
||||
for (const plugin of plugins) {
|
||||
const pluginDestination = path.join(destination, plugin.name);
|
||||
const pluginPrefix = `${plugin.name}/`;
|
||||
allowedFiles.add(`${pluginPrefix}${declaration.plugins.manifestFileName}`);
|
||||
if (
|
||||
!existsSync(
|
||||
path.join(pluginDestination, declaration.plugins.manifestFileName),
|
||||
@@ -1022,17 +1068,24 @@ function pluginTreeMatches(
|
||||
}
|
||||
const stagedDirectory = path.join(pluginDestination, subdirectory.path);
|
||||
if (!subdirectoryEnabled(subdirectory, target, features)) {
|
||||
// 当前目标/feature 下不该出现的子目录:存在即说明是别的构建留下的,必须重建清理。
|
||||
if (existsSync(stagedDirectory)) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const relativePrefix = `${plugin.name}/${subdirectory.path}/`;
|
||||
const sourceRoot = path.join(plugin.root, subdirectory.path);
|
||||
if (subdirectory.origin !== 'source') {
|
||||
if (existsSync(sourceRoot) && !existsSync(stagedDirectory)) {
|
||||
return false;
|
||||
}
|
||||
for (const relative of walkFiles(
|
||||
declaration.plugins,
|
||||
sourceRoot,
|
||||
'插件资源',
|
||||
)) {
|
||||
allowedFiles.add(`${relativePrefix}${relative}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const relative of walkFiles(
|
||||
@@ -1046,6 +1099,7 @@ function pluginTreeMatches(
|
||||
subdirectory.path,
|
||||
relative,
|
||||
);
|
||||
allowedFiles.add(`${relativePrefix}${relative}`);
|
||||
if (!existsSync(staged)) {
|
||||
return false;
|
||||
}
|
||||
@@ -1057,8 +1111,32 @@ function pluginTreeMatches(
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const staging of declaration.plugins.libraryStaging ?? []) {
|
||||
if (
|
||||
plugin.name === staging.plugin &&
|
||||
libraryStagingEnabled(staging, target, features)
|
||||
) {
|
||||
for (const relative of staging.files) {
|
||||
allowedFiles.add(
|
||||
`${plugin.name}/${staging.sourceSubdirectory}/${relative}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const payload of declaration.plugins.nativePayloads ?? []) {
|
||||
if (
|
||||
plugin.name === payload.plugin &&
|
||||
nativePayloadEnabled(payload, target, features)
|
||||
) {
|
||||
allowedFiles.add(
|
||||
`${plugin.name}/${payload.destinationSubdirectory}/${payload.destinationFileName ?? payload.sourceFileName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
return collectTreeFiles(destination, '插件随包目录').every((relative) =>
|
||||
allowedFiles.has(relative),
|
||||
);
|
||||
}
|
||||
|
||||
function assertOwnedPluginRoot(destination, plugins) {
|
||||
@@ -1504,10 +1582,16 @@ function preparePlugins({
|
||||
});
|
||||
return {
|
||||
summary: `plugins 重新生成(${plugins.length} 个插件,写入 ${declaration.plugins.destinationDirectory})`,
|
||||
record: { fingerprint },
|
||||
record: {
|
||||
fingerprint: pluginSourceFingerprint(
|
||||
declaration,
|
||||
plugins,
|
||||
target,
|
||||
features,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// 准备全部随包资源;返回逐条汇总,供入口日志与测试断言。
|
||||
export function prepareBundledResources({
|
||||
target = resolveHostTarget(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -286,6 +287,41 @@ function prepare(fixture, 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 {
|
||||
@@ -824,3 +860,300 @@ test('delivers editor branch artifacts declared as prepared or library staging',
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -153,6 +153,7 @@ LibraryStaging {
|
||||
targets: &["x86_64-pc-windows-msvc"],
|
||||
features: &["godot-editor-execute"],
|
||||
layout: "godot-bundle",
|
||||
files: &["bin/win-x64/agc_godot_editor.dll", "bin/win-x64/metadata.json", "vendor/LICENSE.txt", "vendor/provenance.json"],
|
||||
}
|
||||
],
|
||||
skip_directory_names: &["target", "node_modules"],
|
||||
|
||||
@@ -59,6 +59,9 @@ pub struct Subdirectory {
|
||||
}
|
||||
|
||||
/// 由外部工具链另行产出的随包库(如 Godot gdextension)的归位规则。
|
||||
///
|
||||
/// `files` 是相对 `source_subdirectory` 的随包文件清单:源码工作区同位目录里还有构建输入与
|
||||
/// 测试,只有这些文件会归位到随包目录,校验也以这份清单为准。
|
||||
#[derive(Debug)]
|
||||
pub struct LibraryStaging {
|
||||
pub plugin: &'static str,
|
||||
@@ -66,6 +69,7 @@ pub struct LibraryStaging {
|
||||
pub targets: &'static [&'static str],
|
||||
pub features: &'static [&'static str],
|
||||
pub layout: &'static str,
|
||||
pub files: &'static [&'static str],
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -290,8 +294,11 @@ pub fn declared_relative_path(relative: &str) -> PathBuf {
|
||||
|
||||
/// 只读校验插件随包工作区。
|
||||
///
|
||||
/// 声明为源码派生的内容必须与仓库源码逐文件一致(清单 + 逐文件 sha256),构建期派生的子目录
|
||||
/// 只要求存在(内容由产出它的构建步骤负责),整树不得出现符号链接;编辑器分支产物不动。
|
||||
/// 校验分两层:先按当前目标与已启用 feature 构造允许文件集合(插件清单、生效的随包子目录、
|
||||
/// 生效的随包库),再要求随包目录由这些文件恰好组成——清单与源码派生的子目录逐文件 sha256
|
||||
/// 一致,构建期派生的子目录与源码工作区同位目录逐文件对齐(内容由产出它的构建步骤负责),
|
||||
/// 随包库的声明文件齐备;集合之外的任何文件一律拒绝(未声明的根条目、源码子目录里的残留文件、
|
||||
/// 未启用 feature 的产物)。整树不得出现符号链接,本函数不做任何写入。
|
||||
pub fn validate_staged_plugins(
|
||||
source_root: &Path,
|
||||
destination_root: &Path,
|
||||
@@ -307,7 +314,36 @@ pub fn validate_staged_plugins(
|
||||
destination_root.display()
|
||||
));
|
||||
}
|
||||
for plugin in plugin_directories(source_root, plugins().manifest_file_name)? {
|
||||
let declared_plugins = plugin_directories(source_root, plugins().manifest_file_name)?;
|
||||
let declared_names = declared_plugins
|
||||
.iter()
|
||||
.map(|plugin| plugin.name.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
for entry in std::fs::read_dir(destination_root).map_err(|error| {
|
||||
format!(
|
||||
"插件随包资源目录不可读 {}:{error}",
|
||||
destination_root.display()
|
||||
)
|
||||
})? {
|
||||
let entry = entry.map_err(|error| format!("插件随包目录项不可读:{error}"))?;
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|error| format!("插件随包目录项类型不可读:{error}"))?;
|
||||
if file_type.is_symlink() {
|
||||
return Err(format!(
|
||||
"插件随包目录不允许符号链接:{}",
|
||||
entry.path().display()
|
||||
));
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !declared_names.contains(name.as_str()) {
|
||||
return Err(format!(
|
||||
"插件随包目录存在未声明条目:{}",
|
||||
entry.path().display()
|
||||
));
|
||||
}
|
||||
}
|
||||
for plugin in &declared_plugins {
|
||||
let staged = destination_root.join(&plugin.name);
|
||||
let source_manifest = plugin.path.join(plugins().manifest_file_name);
|
||||
let staged_manifest = staged.join(plugins().manifest_file_name);
|
||||
@@ -328,39 +364,34 @@ pub fn validate_staged_plugins(
|
||||
staged_manifest.display()
|
||||
));
|
||||
}
|
||||
let mut allowed = BTreeSet::new();
|
||||
allowed.insert(plugins().manifest_file_name.to_string());
|
||||
for subdirectory in plugins().subdirectories {
|
||||
if !subdirectory_enabled(subdirectory, target, &feature_enabled) {
|
||||
if !subdirectory_applies_to_plugin(subdirectory, &plugin.name)
|
||||
|| !subdirectory_enabled(subdirectory, target, &feature_enabled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let relative = declared_relative_path(subdirectory.path);
|
||||
let source = plugin.path.join(&relative);
|
||||
let source_directory = plugin.path.join(&relative);
|
||||
let staged_directory = staged.join(&relative);
|
||||
if subdirectory_is_prepared(subdirectory) {
|
||||
if source.exists() && !staged_directory.is_dir() {
|
||||
return Err(format!(
|
||||
"随包构建期产物缺失:{}(插件 {})",
|
||||
staged_directory.display(),
|
||||
plugin.name
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !source.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if !staged_directory.is_dir() {
|
||||
return Err(format!(
|
||||
"随包插件缺少必需目录:{}(插件 {})",
|
||||
staged_directory.display(),
|
||||
plugin.name
|
||||
));
|
||||
}
|
||||
for relative_file in collect_sources(&source)? {
|
||||
let source_file = source.join(declared_relative_path(&relative_file));
|
||||
let staged_file = staged_directory.join(declared_relative_path(&relative_file));
|
||||
for file in source_subdirectory_files(&source_directory)? {
|
||||
allowed.insert(format!("{}/{}", subdirectory.path, file));
|
||||
let staged_file = staged_directory.join(declared_relative_path(&file));
|
||||
if !staged_file.is_file() {
|
||||
return Err(format!("随包插件缺少文件:{}", staged_file.display()));
|
||||
return Err(if subdirectory_is_prepared(subdirectory) {
|
||||
format!(
|
||||
"随包已准备产物缺少文件:{}(请先执行随包资源准备步骤)",
|
||||
staged_file.display()
|
||||
)
|
||||
} else {
|
||||
format!("随包插件缺少文件:{}", staged_file.display())
|
||||
});
|
||||
}
|
||||
if !subdirectory_is_source_derived(subdirectory) {
|
||||
continue;
|
||||
}
|
||||
let source_file = source_directory.join(declared_relative_path(&file));
|
||||
let source_digest = sha256_file(&source_file).map_err(|error| {
|
||||
format!("读取插件文件失败 {}:{error}", source_file.display())
|
||||
})?;
|
||||
@@ -375,11 +406,45 @@ pub fn validate_staged_plugins(
|
||||
}
|
||||
}
|
||||
}
|
||||
for staging in plugins().library_staging {
|
||||
if staging.plugin != plugin.name.as_str()
|
||||
|| !library_staging_enabled(staging, target, &feature_enabled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for file in staging.files {
|
||||
let relative = format!("{}/{}", staging.source_subdirectory, file);
|
||||
let staged_file = staged.join(declared_relative_path(&relative));
|
||||
if !staged_file.is_file() {
|
||||
return Err(format!(
|
||||
"随包库缺少声明文件:{}(请先执行随包资源准备步骤)",
|
||||
staged_file.display()
|
||||
));
|
||||
}
|
||||
allowed.insert(relative);
|
||||
}
|
||||
}
|
||||
for relative in collect_tree_files(&staged)? {
|
||||
if !allowed.contains(&relative) {
|
||||
return Err(format!(
|
||||
"随包插件存在未声明文件:{}(目标 {target} 与当前 feature 组合不允许;请先执行随包资源准备步骤)",
|
||||
staged.join(declared_relative_path(&relative)).display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
collect_tree_files(destination_root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 源码工作区同位子目录里的文件集合;该目录不存在时为空集。
|
||||
fn source_subdirectory_files(source_directory: &Path) -> Result<BTreeSet<String>, String> {
|
||||
if !source_directory.is_dir() {
|
||||
return Ok(BTreeSet::new());
|
||||
}
|
||||
collect_sources(source_directory)
|
||||
}
|
||||
|
||||
/// 声明为「由准备步骤按源码派生」的子目录。
|
||||
pub fn subdirectory_is_source_derived(subdirectory: &Subdirectory) -> bool {
|
||||
subdirectory.origin == "source"
|
||||
@@ -1124,6 +1189,29 @@ mod tests {
|
||||
.expect("write staged entry");
|
||||
}
|
||||
|
||||
/// 在源码与随包目录各写一份同名同内容的插件目录(随包侧即准备步骤的复制结果)。
|
||||
fn write_plugin_copy(
|
||||
source_root: &Path,
|
||||
destination_root: &Path,
|
||||
plugin_name: &str,
|
||||
relative_files: &[&str],
|
||||
) {
|
||||
for root in [source_root, destination_root] {
|
||||
let plugin = root.join(plugin_name);
|
||||
fs::create_dir_all(&plugin).expect("create plugin");
|
||||
fs::write(
|
||||
plugin.join("plugin.json"),
|
||||
format!("{{\"name\":\"{plugin_name}\"}}\n"),
|
||||
)
|
||||
.expect("write manifest");
|
||||
for relative in relative_files {
|
||||
let path = plugin.join(declared_relative_path(relative));
|
||||
fs::create_dir_all(path.parent().expect("path parent")).expect("create parent");
|
||||
fs::write(&path, format!("{relative}\n")).expect("write file");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_plugins_are_checked_against_repository_sources() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
@@ -1205,6 +1293,189 @@ mod tests {
|
||||
assert!(error.contains("符号链接"), "{error}");
|
||||
}
|
||||
|
||||
/// 随包目录里出现源码侧不存在的文件(源码子目录残留、插件根目录未知文件、未声明的根条目)必须被拒绝。
|
||||
#[test]
|
||||
fn staged_plugins_reject_undeclared_files() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let source_root = temp.path().join("plugins");
|
||||
let destination_root = temp.path().join("resources/plugins");
|
||||
write_plugin_fixture(&source_root, &destination_root);
|
||||
|
||||
let leftover = destination_root.join("agc-demo-editor/src/leftover.mjs");
|
||||
fs::write(&leftover, "stale\n").expect("write leftover");
|
||||
let error = validate_staged_plugins(
|
||||
&source_root,
|
||||
&destination_root,
|
||||
"aarch64-apple-darwin",
|
||||
|_| true,
|
||||
)
|
||||
.expect_err("必须拒绝源码子目录里的残留文件");
|
||||
assert!(error.contains("未声明文件"), "{error}");
|
||||
assert!(error.contains("leftover.mjs"), "{error}");
|
||||
fs::remove_file(&leftover).expect("remove leftover");
|
||||
|
||||
let root_file = destination_root.join("agc-demo-editor/README.md");
|
||||
fs::write(&root_file, "stale\n").expect("write plugin root file");
|
||||
let error = validate_staged_plugins(
|
||||
&source_root,
|
||||
&destination_root,
|
||||
"aarch64-apple-darwin",
|
||||
|_| true,
|
||||
)
|
||||
.expect_err("必须拒绝插件根目录的未知文件");
|
||||
assert!(error.contains("未声明文件"), "{error}");
|
||||
fs::remove_file(&root_file).expect("remove plugin root file");
|
||||
|
||||
let stray = destination_root.join("stray.txt");
|
||||
fs::write(&stray, "stale\n").expect("write stray entry");
|
||||
let error = validate_staged_plugins(
|
||||
&source_root,
|
||||
&destination_root,
|
||||
"aarch64-apple-darwin",
|
||||
|_| true,
|
||||
)
|
||||
.expect_err("必须拒绝未声明的根条目");
|
||||
assert!(error.contains("未声明条目"), "{error}");
|
||||
}
|
||||
|
||||
/// 源码侧删掉文件后,随包目录里的旧副本属于未声明文件(随包目录不接受比源码多出的内容)。
|
||||
#[test]
|
||||
fn staged_plugins_reject_files_removed_from_sources() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let source_root = temp.path().join("plugins");
|
||||
let destination_root = temp.path().join("resources/plugins");
|
||||
write_plugin_fixture(&source_root, &destination_root);
|
||||
fs::remove_file(source_root.join("agc-demo-editor/src/entry.mjs")).expect("remove source");
|
||||
let error = validate_staged_plugins(
|
||||
&source_root,
|
||||
&destination_root,
|
||||
"aarch64-apple-darwin",
|
||||
|_| true,
|
||||
)
|
||||
.expect_err("源码删除后的随包副本必须被拒绝");
|
||||
assert!(error.contains("未声明文件"), "{error}");
|
||||
assert!(error.contains("entry.mjs"), "{error}");
|
||||
}
|
||||
|
||||
/// Cocos 的 `native/payload` 由构建产出:只在 windows 且 feature 开启时随包,其余组合下其产物必须被拒绝。
|
||||
#[test]
|
||||
fn staged_plugins_reject_prepared_artifacts_of_disabled_features() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let source_root = temp.path().join("plugins");
|
||||
let destination_root = temp.path().join("resources/plugins");
|
||||
write_plugin_copy(
|
||||
&source_root,
|
||||
&destination_root,
|
||||
"agc-cocos-editor",
|
||||
&["src/entry.mjs", "native/payload/cocos-editor-bridge.dll"],
|
||||
);
|
||||
validate_staged_plugins(
|
||||
&source_root,
|
||||
&destination_root,
|
||||
"x86_64-pc-windows-msvc",
|
||||
|name| name == "cocos-editor-injection",
|
||||
)
|
||||
.expect("feature 开启时 prepared 产物合法");
|
||||
|
||||
let error = validate_staged_plugins(
|
||||
&source_root,
|
||||
&destination_root,
|
||||
"x86_64-pc-windows-msvc",
|
||||
|_| false,
|
||||
)
|
||||
.expect_err("feature 关闭后必须拒绝 prepared 产物");
|
||||
assert!(error.contains("未声明文件"), "{error}");
|
||||
assert!(error.contains("cocos-editor-bridge.dll"), "{error}");
|
||||
|
||||
let error = validate_staged_plugins(
|
||||
&source_root,
|
||||
&destination_root,
|
||||
"aarch64-apple-darwin",
|
||||
|_| true,
|
||||
)
|
||||
.expect_err("目标不含 windows 时必须拒绝 prepared 产物");
|
||||
assert!(error.contains("未声明文件"), "{error}");
|
||||
}
|
||||
|
||||
/// 随包库只归位声明里的文件:源码工作区的构建输入不进随包目录,随包目录多出或缺少文件都必须被拒绝。
|
||||
#[test]
|
||||
fn staged_plugins_follow_declared_library_staging_files() {
|
||||
let staging = PLUGINS
|
||||
.library_staging
|
||||
.iter()
|
||||
.find(|entry| entry.layout == "godot-bundle")
|
||||
.expect("godot staging");
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let source_root = temp.path().join("plugins");
|
||||
let destination_root = temp.path().join("resources/plugins");
|
||||
let mut relative_files = vec!["src/entry.mjs".to_string()];
|
||||
relative_files.extend(
|
||||
staging
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| format!("{}/{}", staging.source_subdirectory, file)),
|
||||
);
|
||||
let relative_files = relative_files
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
write_plugin_copy(
|
||||
&source_root,
|
||||
&destination_root,
|
||||
"agc-godot-editor",
|
||||
&relative_files,
|
||||
);
|
||||
let source_only = source_root.join("agc-godot-editor/native/gdextension/src/native.cpp");
|
||||
fs::create_dir_all(source_only.parent().expect("path parent")).expect("create source dir");
|
||||
fs::write(&source_only, "void build_input() {}\n").expect("write source-only file");
|
||||
|
||||
validate_staged_plugins(
|
||||
&source_root,
|
||||
&destination_root,
|
||||
"x86_64-pc-windows-msvc",
|
||||
|name| name == "godot-editor-execute",
|
||||
)
|
||||
.expect("声明文件齐备时随包库合法");
|
||||
|
||||
let stale =
|
||||
destination_root.join("agc-godot-editor/native/gdextension/bin/win-x64/stale.dll");
|
||||
fs::write(&stale, "stale\n").expect("write stale library file");
|
||||
let error = validate_staged_plugins(
|
||||
&source_root,
|
||||
&destination_root,
|
||||
"x86_64-pc-windows-msvc",
|
||||
|name| name == "godot-editor-execute",
|
||||
)
|
||||
.expect_err("必须拒绝随包库里未声明的文件");
|
||||
assert!(error.contains("未声明文件"), "{error}");
|
||||
fs::remove_file(&stale).expect("remove stale library file");
|
||||
|
||||
let declared = destination_root
|
||||
.join("agc-godot-editor")
|
||||
.join(declared_relative_path(&format!(
|
||||
"{}/{}",
|
||||
staging.source_subdirectory, staging.files[0]
|
||||
)));
|
||||
fs::remove_file(&declared).expect("remove declared library file");
|
||||
let error = validate_staged_plugins(
|
||||
&source_root,
|
||||
&destination_root,
|
||||
"x86_64-pc-windows-msvc",
|
||||
|name| name == "godot-editor-execute",
|
||||
)
|
||||
.expect_err("必须拒绝缺少声明文件的随包库");
|
||||
assert!(error.contains("缺少声明文件"), "{error}");
|
||||
|
||||
let error = validate_staged_plugins(
|
||||
&source_root,
|
||||
&destination_root,
|
||||
"x86_64-pc-windows-msvc",
|
||||
|_| false,
|
||||
)
|
||||
.expect_err("feature 关闭后必须拒绝随包库");
|
||||
assert!(error.contains("未声明文件"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_sources_applies_declared_skip_rules() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
Reference in New Issue
Block a user