import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createHash, createHmac } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, 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')));
}
}
});
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 ??
'',
{ 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('FileAlreadyExists', {
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('所有发布正文均使用自身字节摘要定位,重复打包复用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:
'Enabled',
},
],
[
'Suspended',
{
versioningXml:
'Suspended',
},
],
[
'命名空间Status',
{
versioningXml:
'Enabled',
},
],
['格式错误', { versioningXml: '' }],
['无权限', { 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,
);
});