import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { execFile } from 'node:child_process'; import { access, mkdir, mkdtemp, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; import { fileURLToPath } from 'node:url'; import { deploySite, PROJECT } from './deploy.mjs'; const executeFile = promisify(execFile); async function saveManifest(directory, files, sourceCommit = 'source') { const manifest = Buffer.from(JSON.stringify({ sourceCommit, generatorHash: 'generator', searchIndex: 'search-index-example.json', files: [...files].filter(([path]) => path !== 'asset-manifest.json').map(([path, data]) => ({ path, bytes: data.length, sha256: createHash('sha256').update(data).digest('hex'), })) })); files.set('asset-manifest.json', manifest); await writeFile(join(directory, 'asset-manifest.json'), manifest); return manifest; } async function fixture(t, { notes = ['notes/MySQL.md', 'notes/大模型应用.md', 'notes/Java 基础.md', 'notes/66. 构建乘积数组.md'], images = true } = {}) { const root = await mkdtemp(join(tmpdir(), 'cs-notes-deploy-')); const directory = join(root, 'dist'); t.after(() => rm(root, { recursive: true, force: true })); const files = new Map(['index.html', 'build-info.json', 'README.md', 'site.css', 'site.js', 'search.js', 'search-paths.js', 'search-index-example.json', 'image-variants.json', ...notes, ...(images ? ['notes/pics/example.png', 'optimized/example.webp'] : [])] .map(path => [path, Buffer.from(`validated contents: ${path}`)])); for (const [path, data] of files) { await mkdir(resolve(directory, path, '..'), { recursive: true }); await writeFile(join(directory, path), data); } const manifest = await saveManifest(directory, files); return { directory, files, manifest }; } test('每日检查:清单一致时不请求凭据、不重复发布,同时核对线上实际文件', async t => { const { directory, files } = await fixture(t); const requests = []; const report = await deploySite({ directory, attempts: 1, getClient: () => assert.fail('无变化时不应读取发布凭据'), fetcher: async url => { const path = decodeURIComponent(url.pathname.slice(1)); requests.push(path); return new Response(files.get(path)); }, }); assert.equal(report.result, 'unchanged'); assert.equal(report.verifiedFiles, 15); assert.ok(requests.includes('notes/MySQL.md')); assert.ok(requests.includes('notes/pics/example.png')); assert.ok(requests.includes('optimized/example.webp')); await assert.rejects(access(resolve(directory, '../.cache/site-release.zip')), { code: 'ENOENT' }); }); test('新版本只向固定项目发布 dist,等候 Success 后重试正式域名校验', async t => { const { directory, files } = await fixture(t); let published = false; let firstVerification = true; let retries = 0; const report = await deploySite({ directory, attempts: 2, sleep: async () => { retries++; }, fetcher: async url => { if (!published) return new Response('{}'); if (firstVerification) { firstVerification = false; return new Response('{}'); } return new Response(files.get(decodeURIComponent(url.pathname.slice(1)))); }, getClient: async () => ({ projects: { get: async input => { assert.deepEqual(input, { projectId: PROJECT.projectId }); return PROJECT; } }, deployments: { deploy: async input => { assert.equal(input.projectId, PROJECT.projectId); assert.equal(input.env, 'Production'); assert.equal(input.wait, true); assert.deepEqual(input.artifact, { archive: resolve(directory, '../.cache/site-release.zip') }); published = true; return { status: 'Success', deploymentId: 'deployment-test' }; } }, }), }); assert.equal(report.result, 'deployed'); assert.equal(report.deploymentId, 'deployment-test'); assert.equal(retries, 1); }); test('项目身份不符时拒绝发布', async t => { const { directory } = await fixture(t); await assert.rejects(deploySite({ directory, fetcher: async () => new Response('{}'), getClient: async () => ({ projects: { get: async () => ({ ...PROJECT, name: 'other-site' }) }, deployments: { deploy: () => assert.fail('不能向错误项目上传') }, }), }), /项目标识/); }); test('腾讯云最终状态失败必须让任务失败', async t => { const { directory } = await fixture(t); await assert.rejects(deploySite({ directory, fetcher: async () => new Response('{}'), getClient: async () => ({ projects: { get: async () => PROJECT }, deployments: { deploy: async () => ({ status: 'Failed' }) } }), }), /未成功:Failed/); }); test('HTTP 200 的单页回退不能冒充真实资源', async t => { const { directory, files } = await fixture(t); await assert.rejects(deploySite({ directory, attempts: 1, getClient: () => assert.fail('清单一致时不发布'), fetcher: async url => new Response(url.pathname === '/asset-manifest.json' ? files.get('asset-manifest.json') : 'SPA fallback'), }), /文件校验不一致/); }); test('网页正常但优化图片缺失时不能宣告发布成功', async t => { const { directory, files } = await fixture(t); await assert.rejects(deploySite({ directory, attempts: 1, getClient: () => assert.fail('清单一致时不发布'), fetcher: async url => new Response(url.pathname.endsWith('.webp') ? 'SPA fallback' : files.get(decodeURIComponent(url.pathname.slice(1)))), }), /文件校验不一致:optimized\/example.webp/); }); test('中文或含空格文章返回首页时不能宣告发布成功', async t => { const { directory, files } = await fixture(t); await assert.rejects(deploySite({ directory, attempts: 1, getClient: () => assert.fail('清单一致时不发布'), fetcher: async url => new Response(url.pathname.includes(encodeURIComponent('大模型应用')) ? 'SPA fallback' : files.get(decodeURIComponent(url.pathname.slice(1)))), }), /文件校验不一致:notes\/大模型应用.md/); }); test('文章改名和没有优化配图时仍按清单核验当前有效内容', async t => { const { directory, files } = await fixture(t, { notes: ['notes/new-note.md', 'notes/新笔记.md', 'notes/专题 新内容.md'], images: false }); const requests = []; const report = await deploySite({ directory, attempts: 1, getClient: () => assert.fail('无变化时不读取凭据'), fetcher: async url => { const path = decodeURIComponent(url.pathname.slice(1)); requests.push(path); return new Response(files.get(path)); }, }); assert.equal(report.result, 'unchanged'); assert.ok(requests.includes('notes/新笔记.md')); assert.ok(requests.includes('notes/专题 新内容.md')); }); test('本地清单缺少关键页面时,在读取凭据和上传之前失败', async t => { const { directory, files } = await fixture(t); files.delete('index.html'); await saveManifest(directory, files); await assert.rejects(deploySite({ directory, attempts: 1, fetcher: async () => new Response('{}'), getClient: () => assert.fail('不完整的本地产物不能读取发布凭据'), }), /构建缺少关键文件:index.html/); }); test('文件与清单不一致时,在读取凭据和上传之前失败', async t => { const { directory } = await fixture(t); await writeFile(join(directory, 'README.md'), 'changed after validation'); await assert.rejects(deploySite({ directory, attempts: 1, fetcher: async () => new Response('{}'), getClient: () => assert.fail('内容不一致时不能读取发布凭据'), }), /打包前文件校验不一致/); }); test('已有旧发布包时,上传的 ZIP 必须包含当前清单', async t => { const { directory, files } = await fixture(t); const archive = resolve(directory, '../.cache/site-release.zip'); const script = fileURLToPath(new URL('./package_site.py', import.meta.url)); await executeFile('python3', [script, '--source', directory, '--output', archive]); const manifest = await saveManifest(directory, files, 'new-source'); let published = false; const report = await deploySite({ directory, attempts: 1, fetcher: async url => new Response(published ? files.get(decodeURIComponent(url.pathname.slice(1))) : '{}'), getClient: async () => ({ projects: { get: async () => PROJECT }, deployments: { deploy: async input => { const { stdout } = await executeFile('python3', ['-c', 'import sys, zipfile; print(zipfile.ZipFile(sys.argv[1]).read("asset-manifest.json").decode())', input.artifact.archive]); assert.deepEqual(JSON.parse(stdout), JSON.parse(manifest)); published = true; return { status: 'Success', deploymentId: 'new-release' }; } }, }), }); assert.equal(report.result, 'deployed'); });