361 lines
12 KiB
JavaScript
361 lines
12 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 cpDirectory = path.join(root, 'activity', 'cp');
|
|
const pageDirectory = path.join(cpDirectory, 'huwaa');
|
|
const assetDirectory = path.join(pageDirectory, 'assets', 'layers');
|
|
const manifestPath = path.join(assetDirectory, 'manifest.json');
|
|
|
|
const expectedEntryFrames = {
|
|
plaza: '831:3320',
|
|
rank: '831:4048',
|
|
rewards: '831:3913',
|
|
};
|
|
const expectedDesignSizes = {
|
|
plaza: { width: 1080, height: 4283 },
|
|
rank: { width: 1080, height: 3733 },
|
|
rewards: { width: 1080, height: 4782 },
|
|
};
|
|
const forbiddenAggregateNodes = new Set([
|
|
'543:1998',
|
|
'823:3005',
|
|
'831:3320',
|
|
'831:4048',
|
|
'831:3913',
|
|
'831:3323',
|
|
'831:3326',
|
|
'831:3339',
|
|
'831:3349',
|
|
'831:3642',
|
|
'831:4381',
|
|
'831:3942',
|
|
]);
|
|
|
|
function invariant(condition, message) {
|
|
if (!condition) throw new Error(message);
|
|
}
|
|
|
|
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(expectedEntryFrames),
|
|
'Huwaa CP entry-frame mapping changed'
|
|
);
|
|
invariant(
|
|
JSON.stringify(manifest.figma?.designSizes) ===
|
|
JSON.stringify(expectedDesignSizes),
|
|
'Huwaa CP design sizes changed'
|
|
);
|
|
invariant(Array.isArray(manifest.assets), 'Manifest assets must be an array');
|
|
invariant(manifest.assets.length === 17, 'Manifest must describe 17 layers');
|
|
|
|
function webPDimensions(bytes) {
|
|
invariant(
|
|
bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
|
bytes.subarray(8, 12).toString('ascii') === 'WEBP',
|
|
'Asset is not a WebP file'
|
|
);
|
|
const chunk = bytes.subarray(12, 16).toString('ascii');
|
|
if (chunk === 'VP8X') {
|
|
return {
|
|
width: bytes.readUIntLE(24, 3) + 1,
|
|
height: bytes.readUIntLE(27, 3) + 1,
|
|
};
|
|
}
|
|
if (chunk === 'VP8 ') {
|
|
return {
|
|
width: bytes.readUInt16LE(26) & 0x3fff,
|
|
height: bytes.readUInt16LE(28) & 0x3fff,
|
|
};
|
|
}
|
|
if (chunk === 'VP8L') {
|
|
invariant(bytes[20] === 0x2f, 'Invalid lossless WebP signature');
|
|
const packed = bytes.readUInt32LE(21);
|
|
return {
|
|
width: (packed & 0x3fff) + 1,
|
|
height: ((packed >>> 14) & 0x3fff) + 1,
|
|
};
|
|
}
|
|
throw new Error(`Unsupported WebP chunk ${chunk}`);
|
|
}
|
|
|
|
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(
|
|
!forbiddenAggregateNodes.has(asset.sourceNode),
|
|
`${asset.file}: aggregate Figma 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}: design bounds are required`
|
|
);
|
|
invariant(
|
|
asset.original?.width > 0 && asset.original?.height > 0,
|
|
`${asset.file}: export 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',
|
|
'containsDynamicText',
|
|
'containsControls',
|
|
'containsDynamicMedia',
|
|
]) {
|
|
invariant(
|
|
typeof asset[flag] === 'boolean',
|
|
`${asset.file}: ${flag} must be boolean`
|
|
);
|
|
}
|
|
invariant(
|
|
!asset.containsDynamicText,
|
|
`${asset.file}: rendered dynamic text is forbidden`
|
|
);
|
|
invariant(
|
|
!asset.containsControls,
|
|
`${asset.file}: rendered controls are forbidden`
|
|
);
|
|
invariant(
|
|
!asset.containsDynamicMedia,
|
|
`${asset.file}: dynamic business media is forbidden`
|
|
);
|
|
invariant(!asset.containsText, `${asset.file}: embedded text is forbidden`);
|
|
|
|
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');
|
|
const dimensions = webPDimensions(bytes);
|
|
invariant(info.size === asset.bytes, `${asset.file}: byte size mismatch`);
|
|
invariant(digest === asset.sha256, `${asset.file}: SHA-256 mismatch`);
|
|
invariant(
|
|
dimensions.width === asset.original.width &&
|
|
dimensions.height === asset.original.height,
|
|
`${asset.file}: image dimensions do not match the manifest`
|
|
);
|
|
}
|
|
|
|
const diskAssets = (await readdir(assetDirectory))
|
|
.filter((file) => file !== 'manifest.json')
|
|
.sort();
|
|
invariant(
|
|
JSON.stringify(diskAssets) === JSON.stringify([...listedFiles].sort()),
|
|
'Every Huwaa CP visual asset must have exactly one manifest entry'
|
|
);
|
|
|
|
const [html, css, js, theme, api, locales] = await Promise.all([
|
|
readFile(path.join(cpDirectory, 'huwaa.html'), 'utf8'),
|
|
readFile(path.join(pageDirectory, 'huwaa.css'), 'utf8'),
|
|
readFile(path.join(pageDirectory, 'huwaa.js'), 'utf8'),
|
|
readFile(path.join(root, 'common', 'theme.css'), 'utf8'),
|
|
readFile(path.join(root, 'common', 'api.js'), 'utf8'),
|
|
Promise.all(
|
|
['en', 'ar', 'tr', 'es'].map((locale) =>
|
|
readFile(
|
|
path.join(root, 'common', 'locales', `${locale}.json`),
|
|
'utf8'
|
|
)
|
|
)
|
|
),
|
|
]);
|
|
const runtimeSource = [html, css, js].join('\n');
|
|
|
|
for (const nodeID of forbiddenAggregateNodes) {
|
|
invariant(
|
|
!runtimeSource.includes(nodeID),
|
|
`Runtime must not reference aggregate Figma node ${nodeID}`
|
|
);
|
|
}
|
|
for (const forbidden of [
|
|
'whole-page',
|
|
'page-screenshot',
|
|
'figma-frame.png',
|
|
'cp-plaza.png',
|
|
'cp-week.png',
|
|
'cp-reward.png',
|
|
]) {
|
|
invariant(
|
|
!runtimeSource.toLowerCase().includes(forbidden),
|
|
`Forbidden aggregate screenshot reference: ${forbidden}`
|
|
);
|
|
}
|
|
|
|
for (const asset of manifest.assets) {
|
|
invariant(
|
|
runtimeSource.includes(asset.file),
|
|
`${asset.file}: traced asset is not consumed by the Huwaa CP page`
|
|
);
|
|
}
|
|
|
|
invariant(theme.includes('--hy-theme-primary'), 'Common theme is invalid');
|
|
invariant(html.includes('../../common/theme.css'), 'Common theme is required');
|
|
invariant(html.includes('../../common/i18n.js'), 'Common i18n is required');
|
|
invariant(html.includes('../../common/api.js'), 'Common API is required');
|
|
invariant(
|
|
html.includes('../../common/params.js'),
|
|
'Common params are required'
|
|
);
|
|
invariant(
|
|
html.includes('data-hy-app-code="huwaa"'),
|
|
'Huwaa app identity is required'
|
|
);
|
|
invariant(
|
|
html.indexOf("window.HyAppDefaultAppCode = 'huwaa'") <
|
|
html.indexOf('../../common/api.js'),
|
|
'Huwaa app code must be configured before common API initialization'
|
|
);
|
|
invariant(
|
|
html.includes("window.HyAppI18nSupported = ['en', 'ar', 'tr', 'es']"),
|
|
'Huwaa CP must restrict its language surface to en/ar/tr/es'
|
|
);
|
|
invariant(api.includes('cpWeeklyRankAPI'), 'CP weekly-rank API is required');
|
|
invariant(
|
|
js.includes('HyAppAPI.cpWeeklyRank.status'),
|
|
'Huwaa CP must consume the weekly-rank API'
|
|
);
|
|
invariant(
|
|
!js.includes('HyAppAPI.cpSpace.intimacyLeaderboard') &&
|
|
js.includes("activityData.plazaState = 'unavailable'"),
|
|
'The cumulative intimacy leaderboard must not be presented as the Plaza gift-event feed'
|
|
);
|
|
invariant(
|
|
js.includes("get('mock') === '1'") && js.includes('isLocalRuntime()'),
|
|
'Visual fixtures must require explicit local mock mode'
|
|
);
|
|
invariant(
|
|
js.includes('Promise.allSettled'),
|
|
'Independent CP API failures must be isolated'
|
|
);
|
|
invariant(
|
|
js.includes('requestGeneration'),
|
|
'Stale asynchronous CP responses must be rejected'
|
|
);
|
|
invariant(
|
|
js.includes('resolveExplicitEndAt') && !js.includes('localStorage'),
|
|
'Countdown must not fabricate and persist a production activity period'
|
|
);
|
|
invariant(
|
|
js.includes('serverTimeOffsetMS') && js.includes('server_time_ms'),
|
|
'Countdown must use the backend server clock'
|
|
);
|
|
invariant(
|
|
js.includes('source.enabled !== false'),
|
|
'The weekly-rank enabled switch must control the page state'
|
|
);
|
|
invariant(
|
|
js.includes('source.rank_no') && js.includes('section.rankNo === rankNo'),
|
|
'Reward tiers must stay aligned to backend rank_no'
|
|
);
|
|
invariant(
|
|
html.includes('hero-art-text-free.webp') &&
|
|
html.includes('class="huwaa-cp-wordmark"') &&
|
|
html.includes('data-i18n="cp.pageTitle"'),
|
|
'The visible event title must be localized DOM text over text-free art'
|
|
);
|
|
invariant(
|
|
html.includes("var aliases = ['app', 'app_code', 'appCode']"),
|
|
'All app-code aliases must resolve to Huwaa before common scripts load'
|
|
);
|
|
invariant(
|
|
css.includes('background: #e98c9b') &&
|
|
css.includes('left: -487px') &&
|
|
css.includes('mix-blend-mode: screen') &&
|
|
css.includes('opacity: 0.5'),
|
|
'The Figma frame fill and petal texture blend must be reconstructed exactly'
|
|
);
|
|
invariant(
|
|
css.includes('#000 88.93%') && css.includes('transparent 100%'),
|
|
'The hero must preserve the Figma bottom fade mask'
|
|
);
|
|
invariant(
|
|
js.includes('stageWrap.style.minHeight = stageWrap.style.height'),
|
|
'Responsive stage height must override the initial minimum height'
|
|
);
|
|
invariant(
|
|
js.includes("activeTab === 'rewards' && !activityData.rewards.length"),
|
|
'Empty and failed Rewards states must not retain the full three-panel stage height'
|
|
);
|
|
invariant(
|
|
!runtimeSource.includes('lalu-coin'),
|
|
'Huwaa CP must not display a Lalu coin asset'
|
|
);
|
|
invariant(
|
|
!runtimeSource.includes('Namename') &&
|
|
!runtimeSource.includes('1234567') &&
|
|
!runtimeSource.includes('DDDDD'),
|
|
'Figma placeholder business data must not be baked into runtime code'
|
|
);
|
|
invariant(
|
|
html.includes('role="tablist"') && html.includes('role="tabpanel"'),
|
|
'CP tabs must expose accessible tab semantics'
|
|
);
|
|
invariant(
|
|
html.includes('aria-live="polite"'),
|
|
'Dynamic CP state must be announced'
|
|
);
|
|
invariant(
|
|
css.includes('prefers-reduced-motion'),
|
|
'Loading motion must respect reduced motion'
|
|
);
|
|
|
|
for (const locale of locales) {
|
|
for (const key of [
|
|
'"cp.countdownLabel"',
|
|
'"cp.huwaaPlazaGiftTitle"',
|
|
'"cp.previousWeekTopTitle"',
|
|
'"cp.previousWeekTopSubtitle"',
|
|
'"cp.rankPodiumLabel"',
|
|
'"cp.rankPlace"',
|
|
'"cp.loading"',
|
|
'"cp.empty"',
|
|
'"cp.error"',
|
|
'"cp.disabled"',
|
|
'"cp.unavailable"',
|
|
'"cp.retry"',
|
|
'"cp.reward"',
|
|
'"cp.rewardCoins"',
|
|
'"cp.rewardTierGold"',
|
|
'"cp.rewardTierSilver"',
|
|
'"cp.rewardTierBronze"',
|
|
'"cp.daysLong"',
|
|
]) {
|
|
invariant(
|
|
locale.includes(key),
|
|
`Required Huwaa CP translation is missing: ${key}`
|
|
);
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`activity/cp/huwaa.html asset audit passed (${manifest.assets.length} traced layers)`
|
|
);
|