import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import crypto from 'node:crypto'; import assert from 'node:assert/strict'; import test from 'node:test'; import { publishBoardV3, MANIFEST_PATH } from '../scripts/publish-board-v3-20261112.mjs'; const sha = bytes => crypto.createHash('sha256').update(bytes).digest('hex'); function fixture() { const base = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-board-v3-test-')); const targetDir = path.join(base, 'site'); fs.mkdirSync(targetDir); const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH)); manifest.homepageSha256 = sha('homepage'); fs.writeFileSync(path.join(targetDir, 'index.html'), 'homepage'); for (const file of manifest.files) { if (file.allowMissing) continue; const target = path.join(targetDir, file.path), bytes = Buffer.from('prior ' + file.path); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, bytes); file.priorNormalizedSha256 = [sha(bytes)]; } const manifestPath = path.join(base, 'manifest.json'); fs.writeFileSync(manifestPath, JSON.stringify(manifest)); return { base, targetDir, manifestPath, manifest }; } test('v3发布检查完整浏览器依赖,原子更新后重复执行无变化,首页不变', () => { const f = fixture(); try { const plan = publishBoardV3(f); assert.equal(plan.readOnly, true); assert.equal(plan.filesChecked, f.manifest.files.length); assert.equal(plan.changes.length, f.manifest.files.length); assert.equal(fs.existsSync(path.join(f.targetDir, 'skills/ksf-workshop-runtime/scripts/course-deliverables.mjs')), false); const result = publishBoardV3({ ...f, apply: true }); assert.equal(result.filesystemVerified, true); assert.equal(result.homepageModified, false); assert.equal(result.businessRecordWrites, 0); assert.deepEqual(publishBoardV3(f).changes, []); assert.equal(fs.readFileSync(path.join(f.targetDir, 'index.html'), 'utf8'), 'homepage'); } finally { fs.rmSync(f.base, { recursive: true, force: true }); } }); test('v3发布拒绝未知现场、额外文件和首页变化,写入失败回退已改文件及新增模块', () => { const f = fixture(); try { const originalManifest = fs.readFileSync(f.manifestPath), prior = new Map(); for (const file of f.manifest.files) if (!file.allowMissing) prior.set(file.path, fs.readFileSync(path.join(f.targetDir, file.path))); assert.throws(() => publishBoardV3({ ...f, apply: true }, { beforeWrite(relative) { if (relative === 'src/pages/view-report.js') throw Error('simulated write failure'); } }), error => error.rollbackComplete === true); for (const file of f.manifest.files) { const target = path.join(f.targetDir, file.path); if (file.allowMissing) assert.equal(fs.existsSync(target), false); else assert.deepEqual(fs.readFileSync(target), prior.get(file.path)); } f.manifest.files.push({ path: 'server/library-api.mjs' }); fs.writeFileSync(f.manifestPath, JSON.stringify(f.manifest)); assert.throws(() => publishBoardV3({ ...f, apply: true }), { code: 'UNEXPECTED_FILE_LIST' }); fs.writeFileSync(f.manifestPath, originalManifest); const unknown = f.manifest.files.find(file => !file.allowMissing).path; fs.writeFileSync(path.join(f.targetDir, unknown), 'concurrent content'); assert.throws(() => publishBoardV3({ ...f, apply: true }), { code: 'UNKNOWN_TARGET_CONTENT' }); fs.writeFileSync(path.join(f.targetDir, unknown), prior.get(unknown)); fs.writeFileSync(path.join(f.targetDir, 'index.html'), 'other homepage'); assert.throws(() => publishBoardV3({ ...f, apply: true }), { code: 'HOMEPAGE_MISMATCH' }); } finally { fs.rmSync(f.base, { recursive: true, force: true }); } });