519 lines
17 KiB
JavaScript
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`
|
|
);
|