hyapp-h5/scripts/check-activity-cp-fami-layer-assets.mjs
2026-07-17 11:54:43 +08:00

212 lines
6.5 KiB
JavaScript

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)`
);