hyapp-h5/scripts/check-activity-week-star-huwaa-assets.mjs
2026-07-17 11:54:43 +08:00

519 lines
17 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 repoRoot = path.resolve(scriptDirectory, '..');
const entryDirectory = path.join(repoRoot, 'activity', 'week-star');
const pageDirectory = path.join(entryDirectory, 'huwaa');
const layerDirectory = path.join(pageDirectory, 'assets', 'layers');
const manifestPath = path.join(layerDirectory, 'manifest.json');
const expectedFrames = {
history: {
id: '818:1999',
name: '历史前三',
width: 1080,
height: 2309,
},
weekly: {
id: '818:2066',
name: '本周榜单',
width: 1080,
height: 2309,
},
rewards: {
id: '818:2132',
name: '奖励说明',
width: 1080,
height: 3138,
},
};
const forbiddenRootNodes = new Set([
'543:1998',
'818:1999',
'818:2066',
'818:2132',
]);
const expectedFiles = new Set([
'background-stars.webp',
'date-ribbon.webp',
'gift-frame.webp',
'hero-background.webp',
'rank-1.webp',
'rank-2.webp',
'rank-3.webp',
'reward-heading.webp',
'reward-item-frame.webp',
'reward-item-glow.webp',
'reward-panel.webp',
'sample-gift.webp',
'tab-active.webp',
'tab-inactive.webp',
]);
const aggregateNamePattern =
/(?:whole[-_ ]?page|full[-_ ]?page|page[-_ ]?screenshot|screen[-_ ]?shot|root[-_ ]?frame|entry[-_ ]?frame|figma[-_ ]?frame|aggregate)/i;
function invariant(condition, message) {
if (!condition) {
throw new Error(`[week-star-huwaa-asset-audit] ${message}`);
}
}
function sameSet(actual, expected) {
return (
actual.size === expected.size &&
[...actual].every((value) => expected.has(value))
);
}
function readUInt24LE(bytes, offset) {
return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16);
}
// WebP can store dimensions in VP8X, VP8L, or VP8. Walk RIFF chunks instead of
// assuming the image payload is the first chunk, because alpha/metadata chunks
// may precede it in otherwise valid exports.
function webpDimensions(bytes, filename) {
invariant(
bytes.length >= 20 &&
bytes.toString('ascii', 0, 4) === 'RIFF' &&
bytes.toString('ascii', 8, 12) === 'WEBP',
`${filename}: file is not a RIFF WebP`
);
let offset = 12;
while (offset + 8 <= bytes.length) {
const type = bytes.toString('ascii', offset, offset + 4);
const size = bytes.readUInt32LE(offset + 4);
const payload = offset + 8;
invariant(
payload + size <= bytes.length,
`${filename}: truncated ${type} WebP chunk`
);
if (type === 'VP8X') {
invariant(size >= 10, `${filename}: invalid VP8X chunk`);
return {
width: 1 + readUInt24LE(bytes, payload + 4),
height: 1 + readUInt24LE(bytes, payload + 7),
};
}
if (type === 'VP8L') {
invariant(
size >= 5 && bytes[payload] === 0x2f,
`${filename}: invalid VP8L signature`
);
const packed = bytes.readUInt32LE(payload + 1);
return {
width: 1 + (packed & 0x3fff),
height: 1 + ((packed >>> 14) & 0x3fff),
};
}
if (type === 'VP8 ') {
invariant(
size >= 10 &&
bytes[payload + 3] === 0x9d &&
bytes[payload + 4] === 0x01 &&
bytes[payload + 5] === 0x2a,
`${filename}: invalid VP8 frame header`
);
return {
width: bytes.readUInt16LE(payload + 6) & 0x3fff,
height: bytes.readUInt16LE(payload + 8) & 0x3fff,
};
}
offset = payload + size + (size & 1);
}
throw new Error(
`[week-star-huwaa-asset-audit] ${filename}: no WebP image chunk found`
);
}
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
invariant(manifest.schemaVersion === 1, 'unsupported manifest schema');
invariant(
manifest.figma?.fileKey === 'OTOg5cwbhVNWxkImGmzTJl',
'unexpected Figma file key'
);
invariant(manifest.figma?.canvas?.id === '543:1998', 'unexpected Figma canvas');
invariant(
JSON.stringify(manifest.figma?.entryFrames) ===
JSON.stringify(expectedFrames),
'entry-frame mapping changed'
);
const manifestForbiddenNodes = new Set(
manifest.policy?.forbiddenSourceNodes || []
);
for (const node of forbiddenRootNodes) {
invariant(
manifestForbiddenNodes.has(node),
`required root/canvas-node ban is missing: ${node}`
);
}
const maxLayer = manifest.policy?.maxReasonableFileLayer;
invariant(
maxLayer?.width > 0 && maxLayer?.height > 0 && maxLayer?.pixels > 0,
'reasonable single-layer size policy is missing'
);
const assets = manifest.assets;
invariant(Array.isArray(assets), 'manifest assets must be an array');
invariant(assets.length === expectedFiles.size, 'manifest asset count changed');
const assetsByFile = new Map();
for (const asset of assets) {
invariant(
typeof asset.filename === 'string' &&
path.basename(asset.filename) === asset.filename,
'asset filename must be a basename'
);
invariant(
!assetsByFile.has(asset.filename),
`duplicate manifest asset: ${asset.filename}`
);
assetsByFile.set(asset.filename, asset);
invariant(
!aggregateNamePattern.test(asset.filename),
`${asset.filename}: aggregate/root/screenshot-style filename is forbidden`
);
for (const pattern of manifest.policy?.forbiddenFilenamePatterns || []) {
invariant(
!asset.filename
.toLowerCase()
.includes(String(pattern).toLowerCase()),
`${asset.filename}: forbidden filename pattern ${pattern}`
);
}
invariant(
/^\d+:\d+$/.test(asset.sourceNode || ''),
`${asset.filename}: sourceNode is required`
);
invariant(
!forbiddenRootNodes.has(asset.sourceNode),
`${asset.filename}: canvas/root frame cannot be an asset source`
);
invariant(
Array.isArray(asset.sourceNodes) &&
asset.sourceNodes.length > 0 &&
asset.sourceNodes.includes(asset.sourceNode),
`${asset.filename}: complete sourceNodes trace is required`
);
for (const node of asset.sourceNodes) {
invariant(
/^\d+:\d+$/.test(node) && !forbiddenRootNodes.has(node),
`${asset.filename}: invalid or aggregate source node ${node}`
);
}
invariant(
asset.sourceType === 'RECTANGLE',
`${asset.filename}: source must be an isolated image-fill rectangle`
);
invariant(
typeof asset.sourceName === 'string' && asset.sourceName.length > 0,
`${asset.filename}: sourceName is required`
);
invariant(
/^[a-f0-9]{40}$/.test(asset.sourceImageHash || ''),
`${asset.filename}: exact Figma sourceImageHash is required`
);
invariant(
typeof asset.use === 'string' && asset.use.length > 0,
`${asset.filename}: use is required`
);
invariant(
String(asset.kind || '').startsWith('single-'),
`${asset.filename}: asset is not classified as one visual layer`
);
invariant(
asset.renderedDesignSize?.width > 0 &&
asset.renderedDesignSize?.height > 0,
`${asset.filename}: rendered design dimensions are required`
);
invariant(
asset.file?.format === 'webp' &&
Number.isInteger(asset.file?.pixelWidth) &&
Number.isInteger(asset.file?.pixelHeight) &&
Number.isInteger(asset.file?.bytes) &&
asset.file.pixelWidth > 0 &&
asset.file.pixelHeight > 0 &&
asset.file.bytes > 0,
`${asset.filename}: actual file dimensions/bytes are required`
);
invariant(
/^[a-f0-9]{64}$/.test(asset.sha256 || ''),
`${asset.filename}: SHA-256 is required`
);
for (const flag of [
'containsText',
'containsControls',
'containsDynamicBusinessMedia',
'mockOnly',
]) {
invariant(
typeof asset[flag] === 'boolean',
`${asset.filename}: ${flag} must be boolean`
);
}
invariant(
!asset.containsText,
`${asset.filename}: localized/readable copy must not be baked into assets`
);
invariant(
!asset.containsControls,
`${asset.filename}: controls must remain DOM elements`
);
if (asset.scope === 'runtime') {
invariant(
!asset.containsDynamicBusinessMedia && !asset.mockOnly,
`${asset.filename}: production layers cannot contain mock/dynamic business media`
);
} else if (asset.scope === 'fixture') {
invariant(
asset.filename === 'sample-gift.webp' &&
asset.containsDynamicBusinessMedia &&
asset.mockOnly,
`${asset.filename}: only the explicit mock gift may be a fixture`
);
} else {
invariant(false, `${asset.filename}: unsupported scope ${asset.scope}`);
}
invariant(
asset.file.pixelWidth <= maxLayer.width &&
asset.file.pixelHeight <= maxLayer.height &&
asset.file.pixelWidth * asset.file.pixelHeight <= maxLayer.pixels,
`${asset.filename}: file exceeds a reasonable single-layer size`
);
invariant(
!(asset.file.pixelWidth >= 1000 && asset.file.pixelHeight >= 2200),
`${asset.filename}: whole-screen-sized image is forbidden`
);
const assetPath = path.join(layerDirectory, asset.filename);
const [bytes, fileInfo] = await Promise.all([
readFile(assetPath),
stat(assetPath),
]);
const digest = createHash('sha256').update(bytes).digest('hex');
const dimensions = webpDimensions(bytes, asset.filename);
invariant(
fileInfo.size === asset.file.bytes,
`${asset.filename}: byte size mismatch`
);
invariant(digest === asset.sha256, `${asset.filename}: SHA-256 mismatch`);
invariant(
dimensions.width === asset.file.pixelWidth &&
dimensions.height === asset.file.pixelHeight,
`${asset.filename}: WebP dimensions mismatch`
);
}
invariant(
sameSet(new Set(assetsByFile.keys()), expectedFiles),
'manifest must describe exactly the expected 14 traced files'
);
const diskFiles = new Set(
(await readdir(layerDirectory)).filter((file) => file !== 'manifest.json')
);
invariant(
sameSet(diskFiles, expectedFiles),
'every layer file must have exactly one manifest entry; no source/root screenshots may be stored here'
);
const hero = assetsByFile.get('hero-background.webp');
invariant(
hero?.kind === 'single-derived-background',
'derived hero kind changed'
);
invariant(
hero?.sourceImageHash === 'cbc4013cd84d895e3a3ebf260e42b1013066c6ae',
'hero exact-fill source hash changed'
);
invariant(
hero?.derivedFrom?.kind === 'exact-figma-image-fill' &&
hero.derivedFrom.sourceImageHash === hero.sourceImageHash &&
hero.derivedFrom.tool === 'built-in image_gen' &&
hero.derivedFrom.sourceContainedReadableText === true &&
/removed only the embedded readable title\/date text/i.test(
hero.derivedFrom.operation || ''
),
'hero must be documented as an exact-fill derivative with only embedded title/date text removed'
);
invariant(
!hero.containsText &&
!hero.containsControls &&
!hero.containsDynamicBusinessMedia &&
!hero.mockOnly,
'derived hero output must be a text-free, control-free, non-business-media background'
);
invariant(
/decorative hero background only/i.test(hero.use) &&
/DOM\/i18n\/API/i.test(hero.use),
'hero purpose must keep title/date in live DOM/i18n/API content'
);
for (const asset of assets) {
if (asset.filename !== hero.filename) {
invariant(
asset.derivedFrom === undefined,
`${asset.filename}: unexpected derived-image declaration`
);
}
}
const [html, css, js, commonTheme, commonAPI, localeEntries] =
await Promise.all([
readFile(path.join(entryDirectory, 'huwaa.html'), 'utf8'),
readFile(path.join(pageDirectory, 'huwaa.css'), 'utf8'),
readFile(path.join(pageDirectory, 'huwaa.js'), 'utf8'),
readFile(path.join(repoRoot, 'common', 'theme.css'), 'utf8'),
readFile(path.join(repoRoot, 'common', 'api.js'), 'utf8'),
Promise.all(
['en', 'ar', 'tr', 'es'].map(async (language) => ({
language,
messages: JSON.parse(
await readFile(
path.join(
repoRoot,
'common',
'locales',
`${language}.json`
),
'utf8'
)
),
}))
),
]);
const runtimeSource = `${html}\n${css}\n${js}`;
for (const asset of assets) {
invariant(
runtimeSource.includes(asset.filename),
`${asset.filename}: traced asset is not referenced by the page`
);
}
for (const reference of manifest.policy?.forbiddenRuntimeReferences || []) {
invariant(
!runtimeSource.toLowerCase().includes(String(reference).toLowerCase()),
`forbidden root/source/screenshot runtime reference: ${reference}`
);
}
invariant(
!aggregateNamePattern.test(runtimeSource),
'runtime source contains an aggregate/root/page-screenshot reference'
);
// Any literal local bitmap referenced by HTML/CSS/JS must resolve to the traced
// layer inventory. Remote API media is dynamic and therefore is not a literal.
for (const match of runtimeSource.matchAll(
/["'(]\s*([^"')\s]+\.(?:webp|png|jpe?g|avif))(?:\?[^"')\s]*)?/gi
)) {
const literal = match[1];
if (/^(?:https?:|data:)/i.test(literal)) continue;
invariant(
expectedFiles.has(path.basename(literal)),
`untraced local bitmap reference: ${literal}`
);
}
invariant(
html.includes('../../common/theme.css'),
'common/theme.css must be loaded'
);
invariant(
commonTheme.includes('--hy-theme-primary') &&
commonTheme.includes('--hy-theme-bg') &&
commonTheme.includes('--hy-theme-surface'),
'common theme contract is missing required variables'
);
invariant(
html.includes('../../common/i18n.js') && html.includes('data-i18n'),
'common i18n runtime and bindings are required'
);
invariant(
html.includes('../../common/api.js'),
'common API runtime must be loaded'
);
invariant(
html.includes('data-hy-app-code="huwaa"'),
'Huwaa app identity is required'
);
invariant(
html.indexOf("window.HyAppDefaultAppCode = 'huwaa'") >= 0 &&
html.indexOf("window.HyAppDefaultAppCode = 'huwaa'") <
html.indexOf('../../common/api.js'),
'Huwaa app_code must be set before common API initialization'
);
invariant(
commonAPI.includes('var weeklyStarAPI') &&
commonAPI.includes('weeklyStar: weeklyStarAPI'),
'common weekly-star API namespace is missing'
);
for (const call of ['.current()', '.history(1)']) {
invariant(
js.includes(call),
`weekly-star page is missing API call ${call}`
);
}
invariant(
js.includes('HyAppAPI.weeklyStar'),
'page must consume HyAppAPI.weeklyStar'
);
invariant(
js.includes('data.top_entries') && !js.includes('.leaderboard('),
'page must consume current.top_entries without a duplicate leaderboard request'
);
invariant(
js.includes("'duration_ms'") && js.includes('/ DAY_MS'),
'reward validity must consume the gateway duration_ms contract'
);
invariant(
/\.get\(\s*['"]mock['"]\s*\)\s*===\s*['"]1['"]/.test(js),
'visual fixture must require explicit ?mock=1'
);
invariant(
/if\s*\(\s*useMock\s*\)\s*loadMock\(\)\s*;?\s*else\s*loadReal\(\)/.test(js),
'mock and production loading paths must be explicitly separated'
);
invariant(
js.includes('sample-gift.webp') &&
!html.includes('sample-gift.webp') &&
!css.includes('sample-gift.webp'),
'sample gift must remain a JS-gated mock fixture, not production markup/style'
);
invariant(
!/catch\s*\([^)]*\)\s*\{[\s\S]{0,1200}(?:loadMock|mockState)\s*\(/.test(js),
'API failures must not silently fall back to mock business data'
);
const translationKeys = new Set();
for (const source of [html, js]) {
for (const match of source.matchAll(/huwaa_weekly_star\.[a-z0-9_.-]+/gi)) {
translationKeys.add(match[0]);
}
}
invariant(translationKeys.size > 0, 'no Huwaa Weekly Star i18n keys found');
for (const { language, messages } of localeEntries) {
for (const key of translationKeys) {
invariant(
Object.prototype.hasOwnProperty.call(messages, key) &&
typeof messages[key] === 'string' &&
messages[key].trim().length > 0,
`${language}: missing or empty translation ${key}`
);
}
}
console.log(
`[week-star-huwaa-asset-audit] passed: ${assets.length} traced layers, hashes/dimensions/runtime references/theme/i18n/API/mock gating verified`
);