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