c5afc02e94
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
- scripts/agc-template-library-publish.mjs:readProjectFiles 增加正文门禁,任意层级拒绝 .agent/.git/.svn/node_modules,根目录拒绝 dist/build/library/temp/local/.idea/.vscode - scripts/agc-template-library-publish.test.mjs:新增夹具源目录与门禁用例,覆盖 13 条拒绝路径及 game/dist/**、game/.gitignore 放行 - server-rs/crates/api-server/src/admin_templates.rs:上传归档校验与 CLI 用同一份段名单,新增 template_import_archive_rejects_identity_and_build_directories - docs/【模板规范】AGC模板包组织指南-2026-09-21.md:已知边界改为「已有机器门禁」,并说明同名目录段只在根目录受限 - docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md:CLI 与后台上传的校验口径补门禁,并追加 2026-09-21 证据(九个模板本地重打包摘要与线上清单逐条一致) - docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md:验收标准 2 补充门禁证据 - docs/project-memory/shared-memory/decision-log.md:新增 2026-09-21 正文目录门禁决策记录
841 lines
27 KiB
JavaScript
841 lines
27 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { createHash, createHmac } from 'node:crypto';
|
|
import {
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
import test from 'node:test';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
import JSZip from 'jszip';
|
|
|
|
import {
|
|
buildLibrary,
|
|
createClient,
|
|
mergeLibraryIndex,
|
|
publishLibrary,
|
|
} from './agc-template-library-publish.mjs';
|
|
|
|
const source = fileURLToPath(
|
|
new URL('../apps/ai-game-creator-shell/template-library/', import.meta.url),
|
|
);
|
|
const ids = [
|
|
'cocos-empty-2d',
|
|
'cocos-empty-3d',
|
|
'cocos-empty-3d-hq',
|
|
'cocos-hello-world',
|
|
];
|
|
|
|
test('官方 Cocos 模板生成完整且可重复的原生项目包', async () => {
|
|
const first = buildLibrary(source, 'templates', ids);
|
|
const second = buildLibrary(source, 'templates', ids);
|
|
assert.deepEqual(
|
|
first.indexJson.templates.map((entry) => entry.id),
|
|
ids,
|
|
);
|
|
assert.equal(first.objects.length, 13);
|
|
for (const entry of first.indexJson.templates) {
|
|
assert.equal(entry.runtime, 'cocos');
|
|
assert.equal(entry.engineVersion, '3.8.8');
|
|
assert.equal(entry.entry, 'package.json');
|
|
const archive = first.objects.find(
|
|
(object) => object.key === entry.zipKey,
|
|
).body;
|
|
assert.deepEqual(
|
|
archive,
|
|
second.objects.find((object) => object.key === entry.zipKey).body,
|
|
);
|
|
assert.equal(
|
|
createHash('sha256').update(archive).digest('hex'),
|
|
entry.zipSha256,
|
|
);
|
|
const zip = await JSZip.loadAsync(archive, { checkCRC32: true });
|
|
const names = Object.keys(zip.files);
|
|
assert.ok(names.some((name) => name.startsWith('assets/')));
|
|
assert.ok(
|
|
!names.some((name) => /^(game|library|temp|\.agent)\//u.test(name)),
|
|
);
|
|
const pkg = JSON.parse(await zip.file('package.json').async('string'));
|
|
assert.equal(pkg.creator.version, '3.8.8');
|
|
const metadata = JSON.parse(
|
|
first.objects.find((object) => object.key === entry.metadataKey).body,
|
|
);
|
|
assert.equal(names.length, metadata.files.length);
|
|
for (const file of metadata.files) {
|
|
const bytes = await zip.file(file.path).async('nodebuffer');
|
|
assert.equal(bytes.length, file.sizeBytes);
|
|
assert.equal(
|
|
createHash('sha256').update(bytes).digest('hex'),
|
|
file.sha256,
|
|
);
|
|
}
|
|
if (entry.id === 'cocos-empty-2d') {
|
|
assert.equal(
|
|
JSON.parse(
|
|
await zip.file('profiles/v2/packages/scene.json').async('string'),
|
|
)['gizmos-infos'].is2D,
|
|
true,
|
|
);
|
|
}
|
|
if (entry.id === 'cocos-hello-world') {
|
|
const scene = JSON.parse(
|
|
await zip.file('assets/scene/main.scene').async('string'),
|
|
);
|
|
assert.ok(scene.some((item) => item.__type__ === 'cc.Scene'));
|
|
assert.ok(names.some((name) => name.endsWith('.FBX')));
|
|
}
|
|
}
|
|
});
|
|
|
|
function fixtureSource(projectEntries = []) {
|
|
const directory = mkdtempSync(join(tmpdir(), 'agc-template-publish-'));
|
|
const templateRoot = join(directory, 'v1', 'fixture-template');
|
|
const write = (relative, bytes) => {
|
|
const full = join(templateRoot, relative);
|
|
mkdirSync(dirname(full), { recursive: true });
|
|
writeFileSync(full, bytes);
|
|
};
|
|
write(
|
|
'meta.json',
|
|
`${JSON.stringify(
|
|
{
|
|
id: 'fixture-template',
|
|
title: '夹具模板',
|
|
summary: '',
|
|
tags: ['fixture'],
|
|
runtime: 'html',
|
|
entry: 'game/index.html',
|
|
templateVersion: '0.1.0',
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
write('cover.svg', '<svg xmlns="http://www.w3.org/2000/svg"/>\n');
|
|
write('game/index.html', '<html></html>\n');
|
|
for (const relative of projectEntries) write(`project/${relative}`, 'x\n');
|
|
return directory;
|
|
}
|
|
|
|
test('模板正文含身份/版本库/依赖/构建目录时拒绝打包', async () => {
|
|
const rejected = [
|
|
['.agent/manifest.json', '不允许包含 .agent 目录'],
|
|
['game/.agent/ledger.json', '不允许包含 .agent 目录'],
|
|
['.git/config', '不允许包含 .git 目录'],
|
|
['.svn/entries', '不允许包含 .svn 目录'],
|
|
['node_modules/three/package.json', '不允许包含 node_modules 目录'],
|
|
['assets/node_modules/keep.txt', '不允许包含 node_modules 目录'],
|
|
['dist/game.js', '根目录不允许包含 dist 目录'],
|
|
['build/index.html', '根目录不允许包含 build 目录'],
|
|
['library/import.json', '根目录不允许包含 library 目录'],
|
|
['temp/asset.json', '根目录不允许包含 temp 目录'],
|
|
['local/settings.json', '根目录不允许包含 local 目录'],
|
|
['.vscode/settings.json', '根目录不允许包含 .vscode 目录'],
|
|
['.idea/misc.xml', '根目录不允许包含 .idea 目录'],
|
|
];
|
|
for (const [relative, expected] of rejected) {
|
|
const directory = fixtureSource([relative]);
|
|
try {
|
|
assert.throws(
|
|
() => buildLibrary(directory, 'templates'),
|
|
(error) => String(error.message).includes(expected),
|
|
`${relative} 必须被拒绝`,
|
|
);
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
// 同名目录段只在根目录被拒绝:正文内 game/dist/** 属于模板自身内容,.gitignore 也不是 .git。
|
|
const directory = fixtureSource(['game/dist/app.js', 'game/.gitignore']);
|
|
try {
|
|
const built = buildLibrary(directory, 'templates');
|
|
assert.deepEqual(
|
|
built.indexJson.templates.map((entry) => entry.id),
|
|
['fixture-template'],
|
|
);
|
|
const zip = await JSZip.loadAsync(
|
|
built.objects.find(
|
|
(object) => object.key === built.indexJson.templates[0].zipKey,
|
|
).body,
|
|
{ checkCRC32: true },
|
|
);
|
|
assert.ok(Object.keys(zip.files).includes('game/dist/app.js'));
|
|
assert.ok(Object.keys(zip.files).includes('game/.gitignore'));
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
const indexKey = 'templates/index.json';
|
|
const lockKey = 'templates/.publish-lock.json';
|
|
const encode = (value) => Buffer.from(JSON.stringify(value));
|
|
|
|
function library(id = 'cocos-empty-2d') {
|
|
return buildLibrary(source, 'templates', [id]);
|
|
}
|
|
|
|
function oldPublishedLibrary() {
|
|
const built = library();
|
|
const entry = structuredClone(built.indexJson.templates[0]);
|
|
const cover = built.objects.find(
|
|
(object) => object.key === entry.coverKey,
|
|
).body;
|
|
const oldZip = Buffer.from('previous published template');
|
|
Object.assign(entry, {
|
|
templateVersion: '0.0.9',
|
|
zipKey: 'templates/v1/cocos-empty-2d/template.zip',
|
|
zipSizeBytes: oldZip.length,
|
|
zipSha256: digest(oldZip),
|
|
coverKey: 'templates/v1/cocos-empty-2d/cover.svg',
|
|
metadataKey: 'templates/v1/cocos-empty-2d/template.json',
|
|
});
|
|
const indexJson = { ...built.indexJson, templates: [entry] };
|
|
return {
|
|
indexJson,
|
|
objects: [
|
|
{ key: entry.zipKey, body: oldZip },
|
|
{ key: entry.coverKey, body: cover },
|
|
{ key: entry.metadataKey, body: encode({ id: entry.id }) },
|
|
{ key: indexKey, body: encode(indexJson) },
|
|
],
|
|
};
|
|
}
|
|
|
|
function memoryOss(initial = null, options = {}) {
|
|
const objects = new Map(
|
|
(initial?.objects ?? []).map(({ key, body }) => [key, Buffer.from(body)]),
|
|
);
|
|
const calls = [];
|
|
const fetchImpl = async (input, init = {}) => {
|
|
const url = new URL(input);
|
|
assert.equal(url.origin, 'https://fixture-bucket.oss.example.test');
|
|
const request = {
|
|
method: init.method ?? 'GET',
|
|
key: decodeURIComponent(url.pathname.slice(1)),
|
|
url,
|
|
headers: new Headers(init.headers),
|
|
body: init.body == null ? null : Buffer.from(init.body),
|
|
};
|
|
calls.push(request);
|
|
const intercepted = await options.intercept?.(request, objects);
|
|
if (intercepted) return intercepted;
|
|
if (request.method === 'GET' && url.search === '?versioning') {
|
|
return new Response(
|
|
options.versioningXml ??
|
|
'<?xml version="1.0" encoding="UTF-8"?><VersioningConfiguration xmlns="http://doc.oss-cn-hangzhou.aliyuncs.com"/>',
|
|
{ status: options.versioningStatus ?? 200 },
|
|
);
|
|
}
|
|
if (request.method === 'GET') {
|
|
return objects.has(request.key)
|
|
? new Response(Buffer.from(objects.get(request.key)))
|
|
: new Response('', { status: 404 });
|
|
}
|
|
if (request.method === 'PUT') {
|
|
if (
|
|
request.headers.get('x-oss-forbid-overwrite') === 'true' &&
|
|
objects.has(request.key)
|
|
) {
|
|
return new Response('<Error><Code>FileAlreadyExists</Code></Error>', {
|
|
status: 409,
|
|
});
|
|
}
|
|
objects.set(request.key, Buffer.from(request.body));
|
|
return new Response('', { status: 200 });
|
|
}
|
|
if (request.method === 'DELETE') {
|
|
assert.equal(request.key, lockKey, '发布不能删除历史正文');
|
|
objects.delete(request.key);
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
throw new Error(`unexpected request ${request.method} ${request.key}`);
|
|
};
|
|
const client = createClient({
|
|
bucket: 'fixture-bucket',
|
|
endpoint: 'oss.example.test',
|
|
accessKeyId: 'fixture-id',
|
|
accessKeySecret: 'fixture-secret',
|
|
fetchImpl,
|
|
});
|
|
return { client, calls, objects };
|
|
}
|
|
|
|
const publish = (built, store) =>
|
|
publishLibrary(built, {
|
|
client: store.client,
|
|
prefix: 'templates',
|
|
only: built.indexJson.templates.map((item) => item.id),
|
|
log: () => {},
|
|
});
|
|
|
|
test('说明文档随发布覆盖写线上 README,且写在清单之前', async () => {
|
|
const store = memoryOss(oldPublishedLibrary());
|
|
const built = {
|
|
...library(),
|
|
readmeBody: Buffer.from('# AGC 游戏模板库\n\n- 契约说明\n', 'utf8'),
|
|
};
|
|
|
|
await publish(built, store);
|
|
|
|
assert.equal(
|
|
store.objects.get('templates/README.md').toString('utf8'),
|
|
built.readmeBody.toString('utf8'),
|
|
);
|
|
const putKeys = store.calls
|
|
.filter((call) => call.method === 'PUT')
|
|
.map((call) => call.key);
|
|
assert.deepEqual(putKeys.slice(-2), [
|
|
'templates/README.md',
|
|
'templates/index.json',
|
|
]);
|
|
});
|
|
|
|
test('源目录没有 README 时不写线上说明文档', async () => {
|
|
const store = memoryOss(oldPublishedLibrary());
|
|
|
|
await publish({ ...library(), readmeBody: null }, store);
|
|
|
|
assert.equal(store.objects.has('templates/README.md'), false);
|
|
assert.equal(
|
|
store.calls.some(
|
|
(call) => call.method === 'PUT' && call.key === 'templates/README.md',
|
|
),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('所有发布正文均使用自身字节摘要定位,重复打包复用ZIP与封面地址', () => {
|
|
const built = library();
|
|
for (const object of built.objects.filter(
|
|
(object) => object.key !== indexKey,
|
|
)) {
|
|
assert.ok(
|
|
object.key.includes(`/sha256/${digest(object.body)}/`),
|
|
object.key,
|
|
);
|
|
}
|
|
const again = library();
|
|
assert.equal(
|
|
again.indexJson.templates[0].zipKey,
|
|
built.indexJson.templates[0].zipKey,
|
|
);
|
|
assert.equal(
|
|
again.indexJson.templates[0].coverKey,
|
|
built.indexJson.templates[0].coverKey,
|
|
);
|
|
});
|
|
|
|
test('正文全部校验后才发布清单,旧固定路径和未选模板保持不变', async () => {
|
|
const initial = oldPublishedLibrary();
|
|
const store = memoryOss(initial);
|
|
const built = library('cocos-empty-3d');
|
|
const result = await publish(built, store);
|
|
assert.deepEqual(
|
|
result.templates.map((item) => item.id),
|
|
['cocos-empty-2d', 'cocos-empty-3d'],
|
|
);
|
|
assert.deepEqual(result.templates[0], initial.indexJson.templates[0]);
|
|
for (const object of initial.objects.filter(
|
|
(object) => object.key !== indexKey,
|
|
)) {
|
|
assert.deepEqual(store.objects.get(object.key), object.body);
|
|
}
|
|
const commitAt = store.calls.findIndex(
|
|
(call) => call.method === 'PUT' && call.key === indexKey,
|
|
);
|
|
for (const object of built.objects.filter(
|
|
(object) => object.key !== indexKey,
|
|
)) {
|
|
const readAt = store.calls.findIndex(
|
|
(call) => call.method === 'GET' && call.key === object.key,
|
|
);
|
|
assert.ok(readAt >= 0 && readAt < commitAt);
|
|
}
|
|
assert.equal(store.objects.has(lockKey), false);
|
|
});
|
|
|
|
test('清单被明确拒绝后,旧清单仍能下载其原始ZIP且释放自己的锁', async () => {
|
|
const initial = oldPublishedLibrary();
|
|
const store = memoryOss(initial, {
|
|
intercept: async (request) =>
|
|
request.method === 'PUT' && request.key === indexKey
|
|
? new Response('', { status: 403 })
|
|
: undefined,
|
|
});
|
|
await assert.rejects(publish(library(), store));
|
|
assert.deepEqual(
|
|
store.objects.get(indexKey),
|
|
initial.objects.find((object) => object.key === indexKey).body,
|
|
);
|
|
const entry = initial.indexJson.templates[0];
|
|
assert.equal(digest(store.objects.get(entry.zipKey)), entry.zipSha256);
|
|
assert.equal(store.objects.has(lockKey), false);
|
|
});
|
|
|
|
test('正文写入中断不会改坏旧清单,允许后续发布重新取得锁', async () => {
|
|
const initial = oldPublishedLibrary();
|
|
const built = library();
|
|
const store = memoryOss(initial, {
|
|
intercept: async (request) => {
|
|
if (
|
|
request.method === 'PUT' &&
|
|
request.key === built.indexJson.templates[0].coverKey
|
|
)
|
|
throw new Error('body upload interrupted');
|
|
},
|
|
});
|
|
await assert.rejects(publish(built, store), /上传内容对象/u);
|
|
assert.equal(
|
|
store.calls.some((call) => call.method === 'PUT' && call.key === indexKey),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
digest(store.objects.get(initial.indexJson.templates[0].zipKey)),
|
|
initial.indexJson.templates[0].zipSha256,
|
|
);
|
|
assert.equal(store.objects.has(lockKey), false);
|
|
});
|
|
|
|
test('两个真实发布编排互斥,竞争者重新执行时保留前一个发布者的新条目', async () => {
|
|
const initial = library('cocos-empty-3d-hq');
|
|
const a = library('cocos-empty-2d');
|
|
const b = library('cocos-empty-3d');
|
|
let resume;
|
|
let started;
|
|
const paused = new Promise((resolve) => {
|
|
started = resolve;
|
|
});
|
|
const continuation = new Promise((resolve) => {
|
|
resume = resolve;
|
|
});
|
|
let pauseOnce = true;
|
|
const store = memoryOss(initial, {
|
|
intercept: async (request) => {
|
|
if (
|
|
pauseOnce &&
|
|
request.method === 'PUT' &&
|
|
request.key === a.indexJson.templates[0].zipKey
|
|
) {
|
|
pauseOnce = false;
|
|
started();
|
|
await continuation;
|
|
}
|
|
},
|
|
});
|
|
const first = publish(a, store);
|
|
await paused;
|
|
try {
|
|
await assert.rejects(publish(b, store));
|
|
assert.equal(
|
|
store.calls.some(
|
|
(call) =>
|
|
call.method === 'PUT' && call.key === b.indexJson.templates[0].zipKey,
|
|
),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
store.calls.some((call) => call.method === 'DELETE'),
|
|
false,
|
|
);
|
|
} finally {
|
|
resume();
|
|
}
|
|
await first;
|
|
const result = await publish(b, store);
|
|
assert.deepEqual(
|
|
result.templates.map((item) => item.id),
|
|
['cocos-empty-2d', 'cocos-empty-3d', 'cocos-empty-3d-hq'],
|
|
);
|
|
});
|
|
|
|
test('清单PUT回执丢失时保留锁,阻止迟到写入覆盖下一个发布者', async () => {
|
|
const store = memoryOss(null, {
|
|
intercept: async (request) => {
|
|
if (request.method === 'PUT' && request.key === indexKey) {
|
|
delayedCommit = () =>
|
|
store.objects.set(indexKey, Buffer.from(request.body));
|
|
throw new Error('index acknowledgement lost');
|
|
}
|
|
},
|
|
});
|
|
let delayedCommit;
|
|
await assert.rejects(publish(library(), store));
|
|
assert.equal(store.objects.has(lockKey), true);
|
|
await assert.rejects(publish(library('cocos-empty-3d'), store));
|
|
assert.equal(
|
|
store.calls.filter((call) => call.method === 'PUT' && call.key === indexKey)
|
|
.length,
|
|
1,
|
|
);
|
|
assert.equal(
|
|
store.calls.some((call) => call.method === 'DELETE'),
|
|
false,
|
|
);
|
|
delayedCommit();
|
|
assert.deepEqual(
|
|
JSON.parse(store.objects.get(indexKey)).templates.map((item) => item.id),
|
|
['cocos-empty-2d'],
|
|
);
|
|
});
|
|
|
|
test('清单PUT返回5xx也不能立即解锁或自动重发', async () => {
|
|
const store = memoryOss(null, {
|
|
intercept: async (request) =>
|
|
request.method === 'PUT' && request.key === indexKey
|
|
? new Response('', { status: 500 })
|
|
: undefined,
|
|
});
|
|
await assert.rejects(publish(library(), store));
|
|
assert.equal(store.objects.has(lockKey), true);
|
|
assert.equal(
|
|
store.calls.filter((call) => call.method === 'PUT' && call.key === indexKey)
|
|
.length,
|
|
1,
|
|
);
|
|
});
|
|
|
|
test('锁获取响应不明时不能删除可能已存在的锁', async () => {
|
|
const store = memoryOss(null, {
|
|
intercept: async (request, objects) => {
|
|
if (request.method === 'PUT' && request.key === lockKey) {
|
|
objects.set(lockKey, Buffer.from(request.body));
|
|
throw new Error('lock acknowledgement lost');
|
|
}
|
|
},
|
|
});
|
|
await assert.rejects(publish(library(), store));
|
|
assert.equal(store.objects.has(lockKey), true);
|
|
assert.equal(
|
|
store.calls.some((call) => call.method === 'DELETE'),
|
|
false,
|
|
);
|
|
});
|
|
|
|
for (const [label, options] of [
|
|
[
|
|
'Enabled',
|
|
{
|
|
versioningXml:
|
|
'<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>',
|
|
},
|
|
],
|
|
[
|
|
'Suspended',
|
|
{
|
|
versioningXml:
|
|
'<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>',
|
|
},
|
|
],
|
|
[
|
|
'命名空间Status',
|
|
{
|
|
versioningXml:
|
|
'<VersioningConfiguration><x:Status xmlns:x="test">Enabled</x:Status></VersioningConfiguration>',
|
|
},
|
|
],
|
|
['格式错误', { versioningXml: '<wrong/>' }],
|
|
['无权限', { versioningStatus: 403 }],
|
|
]) {
|
|
test(`Bucket版本控制${label}时在任何写入前停止`, async () => {
|
|
const store = memoryOss(null, options);
|
|
await assert.rejects(publish(library(), store));
|
|
assert.equal(
|
|
store.calls.some((call) => call.method !== 'GET'),
|
|
false,
|
|
);
|
|
});
|
|
}
|
|
|
|
test('同版本改ZIP必须拒绝发布,不能让客户端复用旧缓存', async () => {
|
|
const built = library();
|
|
const old = oldPublishedLibrary();
|
|
old.indexJson.templates[0].templateVersion =
|
|
built.indexJson.templates[0].templateVersion;
|
|
old.objects.find((object) => object.key === indexKey).body = encode(
|
|
old.indexJson,
|
|
);
|
|
const store = memoryOss(old);
|
|
await assert.rejects(publish(built, store), /版本/u);
|
|
assert.equal(
|
|
store.calls.some((call) => call.method === 'PUT' && call.key !== lockKey),
|
|
false,
|
|
);
|
|
assert.equal(store.objects.has(lockKey), false);
|
|
});
|
|
|
|
test('更新下架模板保持下架,并保留未选中的后台条目', async () => {
|
|
const initial = library('cocos-empty-2d');
|
|
const hidden = initial.indexJson.templates[0];
|
|
initial.indexJson.inactiveTemplates = [hidden];
|
|
initial.indexJson.templates = [];
|
|
initial.objects.find((object) => object.key === indexKey).body = encode(
|
|
initial.indexJson,
|
|
);
|
|
const store = memoryOss(initial);
|
|
const result = await publish(library('cocos-empty-2d'), store);
|
|
assert.equal(result.templates.length, 0);
|
|
assert.deepEqual(
|
|
result.inactiveTemplates.map((entry) => entry.id),
|
|
['cocos-empty-2d'],
|
|
);
|
|
const next = await publishLibrary(library('cocos-empty-3d'), {
|
|
client: store.client,
|
|
log: () => {},
|
|
});
|
|
assert.deepEqual(
|
|
next.templates.map((entry) => entry.id),
|
|
['cocos-empty-3d'],
|
|
);
|
|
assert.deepEqual(
|
|
next.inactiveTemplates.map((entry) => entry.id),
|
|
['cocos-empty-2d'],
|
|
);
|
|
});
|
|
|
|
test('下架模板也受同版本ZIP一致性门禁保护', async () => {
|
|
const selected = library();
|
|
const initial = oldPublishedLibrary();
|
|
initial.indexJson.templates[0].templateVersion =
|
|
selected.indexJson.templates[0].templateVersion;
|
|
initial.indexJson.inactiveTemplates = initial.indexJson.templates;
|
|
initial.indexJson.templates = [];
|
|
initial.objects.find((object) => object.key === indexKey).body = encode(
|
|
initial.indexJson,
|
|
);
|
|
const store = memoryOss(initial);
|
|
await assert.rejects(publish(selected, store), /版本/u);
|
|
assert.equal(
|
|
store.calls.some((call) => call.method === 'PUT' && call.key !== lockKey),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('active与inactive不能出现同一个模板ID', async () => {
|
|
const initial = library();
|
|
initial.indexJson.inactiveTemplates = structuredClone(
|
|
initial.indexJson.templates,
|
|
);
|
|
initial.objects.find((object) => object.key === indexKey).body = encode(
|
|
initial.indexJson,
|
|
);
|
|
const store = memoryOss(initial);
|
|
await assert.rejects(publish(library(), store), /重复/u);
|
|
assert.equal(
|
|
store.calls.some((call) => call.method === 'PUT' && call.key !== lockKey),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('已有内容地址必须逐字节一致才能复用,不覆盖异常对象', async () => {
|
|
const built = library();
|
|
const store = memoryOss(built);
|
|
await publish(built, store);
|
|
const zipKey = built.indexJson.templates[0].zipKey;
|
|
store.objects.set(zipKey, Buffer.from('corrupt existing content object'));
|
|
const committedBefore = store.calls.filter(
|
|
(call) => call.method === 'PUT' && call.key === indexKey,
|
|
).length;
|
|
await assert.rejects(publish(built, store));
|
|
assert.equal(
|
|
store.calls.filter((call) => call.method === 'PUT' && call.key === indexKey)
|
|
.length,
|
|
committedBefore,
|
|
);
|
|
assert.equal(
|
|
store.objects.get(zipKey).toString(),
|
|
'corrupt existing content object',
|
|
);
|
|
});
|
|
|
|
test('锁owner变化时不删除别人的锁', async () => {
|
|
const otherOwner = encode({
|
|
owner: 'another-publisher',
|
|
startedAt: '2026-09-19T00:00:00Z',
|
|
});
|
|
const store = memoryOss(null, {
|
|
intercept: async (request, objects) => {
|
|
if (request.method === 'PUT' && request.key === indexKey)
|
|
objects.set(lockKey, otherOwner);
|
|
},
|
|
});
|
|
await assert.rejects(publish(library(), store));
|
|
assert.deepEqual(store.objects.get(lockKey), otherOwner);
|
|
assert.equal(
|
|
store.calls.some((call) => call.method === 'DELETE'),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('锁删除失败不能报告发布成功', async () => {
|
|
const store = memoryOss(null, {
|
|
intercept: async (request) =>
|
|
request.method === 'DELETE'
|
|
? new Response('', { status: 503 })
|
|
: undefined,
|
|
});
|
|
await assert.rejects(publish(library(), store));
|
|
assert.equal(store.objects.has(lockKey), true);
|
|
});
|
|
|
|
test('OSS V1签名覆盖禁止覆盖头,版本控制子资源保留尾斜线', async () => {
|
|
const store = memoryOss();
|
|
await store.client.getBucketVersioning();
|
|
const versionRequest = store.calls[0];
|
|
const sign = (text) =>
|
|
`OSS fixture-id:${createHmac('sha1', 'fixture-secret').update(text).digest('base64')}`;
|
|
assert.equal(
|
|
versionRequest.headers.get('authorization'),
|
|
sign(
|
|
`GET\n\n\n${versionRequest.headers.get('date')}\n/fixture-bucket/?versioning`,
|
|
),
|
|
);
|
|
await store.client.put(
|
|
'templates/signature-test',
|
|
Buffer.from('test'),
|
|
'application/json',
|
|
{
|
|
'X-OSS-Meta-Z': ' z ',
|
|
'x-oss-meta-a': 'a',
|
|
'x-oss-forbid-overwrite': 'true',
|
|
},
|
|
);
|
|
const request = store.calls[1];
|
|
assert.equal(
|
|
request.headers.get('authorization'),
|
|
sign(
|
|
`PUT\n\napplication/json\n${request.headers.get('date')}\nx-oss-forbid-overwrite:true\nx-oss-meta-a:a\nx-oss-meta-z:z\n/fixture-bucket/templates/signature-test`,
|
|
),
|
|
);
|
|
});
|
|
|
|
function runOfflineCli(args) {
|
|
const directory = mkdtempSync(
|
|
join(tmpdir(), 'agc-template-publish-cli-test-'),
|
|
);
|
|
const loader = join(directory, 'mock-fetch.mjs');
|
|
const callsPath = join(directory, 'calls.json');
|
|
writeFileSync(join(directory, 'index.json'), encode(library().indexJson));
|
|
writeFileSync(
|
|
loader,
|
|
`
|
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
import net from 'node:net';
|
|
import tls from 'node:tls';
|
|
const calls = [];
|
|
const denyNetwork = () => { throw new Error('real network forbidden'); };
|
|
net.connect = net.createConnection = tls.connect = denyNetwork;
|
|
globalThis.fetch = async (input, init = {}) => {
|
|
const method = init.method ?? 'GET';
|
|
const headers = new Headers(init.headers);
|
|
calls.push({ method, url: String(input), authorization: headers.has('authorization') });
|
|
if (method !== 'GET') throw new Error('dry-run mutation forbidden');
|
|
if (!String(input).endsWith('/templates/index.json')) throw new Error('unexpected request');
|
|
return new Response(readFileSync(new URL('./index.json', import.meta.url)));
|
|
};
|
|
process.on('exit', () => writeFileSync(new URL('./calls.json', import.meta.url), JSON.stringify(calls)));
|
|
`,
|
|
);
|
|
try {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[
|
|
'--import',
|
|
pathToFileURL(loader).href,
|
|
fileURLToPath(
|
|
new URL('./agc-template-library-publish.mjs', import.meta.url),
|
|
),
|
|
'--source',
|
|
source,
|
|
'--only',
|
|
'cocos-empty-2d',
|
|
...args,
|
|
],
|
|
{
|
|
cwd: directory,
|
|
env: {
|
|
...process.env,
|
|
ALIYUN_OSS_ACCESS_KEY_ID: '',
|
|
ALIYUN_OSS_ACCESS_KEY_SECRET: '',
|
|
},
|
|
encoding: 'utf8',
|
|
timeout: 20_000,
|
|
},
|
|
);
|
|
if (result.error) throw result.error;
|
|
return { ...result, calls: JSON.parse(readFileSync(callsPath, 'utf8')) };
|
|
} finally {
|
|
assert.equal(resolve(directory, '..'), resolve(tmpdir()));
|
|
assert.ok(basename(directory).startsWith('agc-template-publish-cli-test-'));
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
test('真实CLI dry-run无需凭据且只读公共清单,不加锁或写入', () => {
|
|
const result = runOfflineCli(['--dry-run']);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.match(result.stdout, /dry-run.*未上传/u);
|
|
assert.ok(result.calls.length > 0);
|
|
assert.ok(
|
|
result.calls.every((call) => call.method === 'GET' && !call.authorization),
|
|
);
|
|
});
|
|
|
|
test('真实CLI拒绝发布时清理历史对象的选项,且不发网络请求', () => {
|
|
const result = runOfflineCli(['--prune']);
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /未知参数/u);
|
|
assert.deepEqual(result.calls, []);
|
|
});
|
|
|
|
test('定向发布保留线上已有模板和库字段,只替换选中的 ID', () => {
|
|
const selected = buildLibrary(source, 'templates', ids).indexJson;
|
|
const oldEntry = {
|
|
id: 'remote-only',
|
|
zipSha256: 'remote-original',
|
|
extra: { preserve: true },
|
|
};
|
|
const existing = {
|
|
...selected,
|
|
note: 'retain',
|
|
templates: [oldEntry, { id: ids[0], templateVersion: 'older' }],
|
|
};
|
|
const snapshot = structuredClone(existing);
|
|
const merged = mergeLibraryIndex(existing, selected);
|
|
assert.deepEqual(existing, snapshot);
|
|
assert.deepEqual(
|
|
merged.templates.find((entry) => entry.id === oldEntry.id),
|
|
oldEntry,
|
|
);
|
|
assert.deepEqual(
|
|
merged.templates.find((entry) => entry.id === ids[0]),
|
|
selected.templates[0],
|
|
);
|
|
assert.equal(merged.templates.length, 5);
|
|
assert.equal(merged.note, 'retain');
|
|
assert.throws(
|
|
() =>
|
|
mergeLibraryIndex({ ...existing, schemaVersion: 'unknown' }, selected),
|
|
/契约/u,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
mergeLibraryIndex(
|
|
{ ...existing, templates: [oldEntry, oldEntry] },
|
|
selected,
|
|
),
|
|
/重复/u,
|
|
);
|
|
assert.throws(
|
|
() => buildLibrary(source, 'templates', ['missing-template']),
|
|
/不存在/u,
|
|
);
|
|
});
|