import { createHash } from 'node:crypto'; import { readFile, readdir, stat } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(scriptDirectory, '..'); const entryDirectory = path.join(root, 'activity', 'cp-space'); const pageDirectory = path.join(entryDirectory, 'fami'); const assetDirectory = path.join(pageDirectory, 'assets', 'layers'); const manifestPath = path.join(assetDirectory, 'manifest.json'); const forbiddenRootFrames = new Set(['859:2027', '859:2247', '859:2454']); function invariant(condition, message) { if (!condition) throw new Error(message); } async function pageText(file) { return readFile(path.join(pageDirectory, file), 'utf8'); } async function entryText(file) { return readFile(path.join(entryDirectory, file), 'utf8'); } const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); invariant(manifest.schemaVersion === 1, 'Unsupported manifest schema'); invariant( manifest.figma?.fileKey === 'vaDp5wwn6AOQVlt9zk0f9r', 'Unexpected Figma file key' ); invariant(manifest.figma?.page?.id === '1:27538', 'Unexpected Figma page'); invariant( JSON.stringify(manifest.figma?.entryFrames) === JSON.stringify({ cp: '859:2027', brother: '859:2247', sister: '859:2454', }), 'Entry-frame mapping changed' ); invariant(Array.isArray(manifest.assets), 'Manifest assets must be an array'); invariant( manifest.assets.length === 27, 'Manifest must describe all 27 assets' ); const listedFiles = new Set(); for (const asset of manifest.assets) { invariant( asset.file && !listedFiles.has(asset.file), `Duplicate asset ${asset.file}` ); listedFiles.add(asset.file); invariant(asset.sourceNode, `${asset.file}: sourceNode is required`); invariant( !forbiddenRootFrames.has(asset.sourceNode), `${asset.file}: root-frame export is forbidden` ); invariant( asset.sourceName && asset.sourceType, `${asset.file}: source name/type is required` ); invariant(asset.purpose, `${asset.file}: purpose is required`); invariant( asset.bounds?.width > 0 && asset.bounds?.height > 0, `${asset.file}: dimensions are required` ); invariant(asset.exportFormat, `${asset.file}: export format is required`); invariant( Number.isInteger(asset.bytes) && asset.bytes > 0, `${asset.file}: byte size is required` ); invariant( /^[a-f0-9]{64}$/.test(asset.sha256 || ''), `${asset.file}: SHA-256 is required` ); for (const flag of [ 'containsText', 'containsControls', 'containsDynamicMedia', ]) { invariant( typeof asset[flag] === 'boolean', `${asset.file}: ${flag} must be boolean` ); } invariant(!asset.containsText, `${asset.file}: rendered text is forbidden`); invariant( !asset.containsControls, `${asset.file}: rendered controls are forbidden` ); invariant( !asset.containsDynamicMedia || asset.testOnly === true, `${asset.file}: dynamic media is allowed only in test-only fixtures` ); const assetPath = path.join(assetDirectory, asset.file); const [bytes, info] = await Promise.all([ readFile(assetPath), stat(assetPath), ]); const digest = createHash('sha256').update(bytes).digest('hex'); invariant(info.size === asset.bytes, `${asset.file}: byte size mismatch`); invariant(digest === asset.sha256, `${asset.file}: SHA-256 mismatch`); } const diskAssets = (await readdir(assetDirectory)) .filter((file) => file !== 'manifest.json') .sort(); invariant( JSON.stringify(diskAssets) === JSON.stringify([...listedFiles].sort()), 'Every visual asset must have exactly one manifest entry' ); const fontPath = path.join( pageDirectory, 'assets', 'fonts', 'source-han-sans-sc-medium-latin.woff2' ); const [fontBytes, fontLicense] = await Promise.all([ readFile(fontPath), readFile(path.join(pageDirectory, 'assets', 'fonts', 'OFL.txt'), 'utf8'), ]); invariant(fontBytes.length === 36604, 'Source Han font subset size mismatch'); invariant( createHash('sha256').update(fontBytes).digest('hex') === '0c5b80890935bdf1ab542338c7f5ebe5c4fc678c76873c843a9c3be1c6b23e57', 'Source Han font subset hash mismatch' ); invariant( fontLicense.includes('SIL OPEN FONT LICENSE'), 'Source Han font license is missing' ); const [html, js, product, css] = await Promise.all([ entryText('fami.html'), pageText('fami.js'), pageText('product.js'), pageText('fami.css'), ]); const runtimeSource = [html, js, product, css].join('\n'); for (const forbidden of [ 'YumiCommon', 'data-hy-app-code="yumi"', 'origin=YUMI', '/gonghui/', '/guild/fami/', '/guide/fami/', '859:2027', '859:2247', '859:2454', ]) { invariant( !runtimeSource.includes(forbidden), `Forbidden runtime identity/reference: ${forbidden}` ); } invariant( product.includes("window.HyAppDefaultAppCode = 'fami'"), 'Fami default app code is missing' ); invariant( html.includes('data-hy-app-code="fami"'), 'Fami root app identity is missing' ); invariant( html.indexOf('./fami/product.js') < html.indexOf('../../common/api.js'), 'product.js must run before common/api.js' ); invariant(html.includes('../../common/theme.css'), 'Common theme is required'); invariant( html.includes('../../common/i18n.js'), 'Common i18n runtime is required' ); invariant( html.includes('source-han-sans-sc-medium-latin.woff2'), 'Figma title font preload is required' ); invariant( js.includes('HyAppAPI.cpSpace'), 'CP Space API namespace is required' ); invariant(js.includes('.relationships({'), 'Relationship API call is required'); invariant( js.includes('relation_type: target'), 'Active tab must be sent as relation_type' ); invariant( js.includes("params.get('mock') === '1'"), 'Mock data must require an explicit query flag' ); invariant( js.includes("layer('mock-avatar.png')"), 'Visual-QA fixture must use the traced avatar' ); invariant( !html.includes('mock-avatar.png'), 'Test avatar must not be a production DOM fallback' ); invariant( !/cancel/i.test(css), 'The three source frames do not contain a cancel control' ); console.log( `activity/cp-space/fami.html asset audit passed (${manifest.assets.length} traced layers)` );