212 lines
6.5 KiB
JavaScript
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)`
|
|
);
|