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

264 lines
8.8 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 pageDirectory = path.join(root, 'coin-seller');
const assetDirectory = path.join(pageDirectory, 'assets', 'fami', 'layers');
const manifestPath = path.join(assetDirectory, 'manifest.json');
const forbiddenAggregateNodes = new Set([
'827:3011',
'827:3012',
'827:3027',
'827:3042',
'827:3057',
'827:3072',
'827:3087',
'827:3014',
'827:3022',
]);
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?.rootFrame?.id === '827:3011',
'Unexpected Figma root frame'
);
invariant(
manifest.figma?.designSize?.width === 1080 &&
manifest.figma?.designSize?.height === 2626 &&
manifest.figma?.designSize?.cssWidth === 360,
'Unexpected design dimensions'
);
invariant(
manifest.runtimeRules?.referenceScreenshotRuntimeUse === false,
'The whole-page reference screenshot must remain QA-only'
);
invariant(
manifest.runtimeRules?.referenceScreenshot ===
'coin-seller/.audit/figma-827-3011.png',
'The QA screenshot path must remain repo-relative and outside runtime assets'
);
invariant(
manifest.runtimeRules?.heroMask?.sourceNode === '827:3089' &&
JSON.stringify(manifest.runtimeRules.heroMask.sourceStops) ===
JSON.stringify([65, 100]) &&
JSON.stringify(manifest.runtimeRules.heroMask.renderedStops) ===
JSON.stringify([83.8, 94.7]),
'The transformed Figma hero mask trace is required'
);
invariant(Array.isArray(manifest.assets), 'Manifest assets must be an array');
invariant(
manifest.assets.length === 4,
'Exactly four traced layers are allowed'
);
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.purpose,
`${asset.file}: source trace and purpose are 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}: original 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`
);
invariant(
asset.containsDynamicText === false,
`${asset.file}: dynamic text is forbidden in assets`
);
invariant(
asset.containsControls === false,
`${asset.file}: rendered controls are forbidden in assets`
);
invariant(
asset.containsBusinessMedia === false,
`${asset.file}: business media is forbidden in assets`
);
invariant(
asset.containsDecorativeWordmark === false ||
asset.sourceNode === '827:3090',
`${asset.file}: only the single Figma hero image may contain its decorative wordmark`
);
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 runtime visual asset must have exactly one manifest entry'
);
const [html, css, js, api, theme, locales] = await Promise.all([
readFile(path.join(pageDirectory, 'fami.html'), 'utf8'),
readFile(path.join(pageDirectory, 'fami.css'), 'utf8'),
readFile(path.join(pageDirectory, 'fami.js'), 'utf8'),
readFile(path.join(root, 'common', 'api.js'), 'utf8'),
readFile(path.join(root, 'common', 'theme.css'), 'utf8'),
Promise.all(
['en', 'ar', 'tr', 'es', 'zh', 'id'].map((locale) =>
readFile(
path.join(root, 'common', 'locales', `${locale}.json`),
'utf8'
)
)
),
]);
const runtimeSource = [html, css, js].join('\n');
const runtimeLower = runtimeSource.toLowerCase();
for (const nodeID of forbiddenAggregateNodes) {
invariant(
!runtimeSource.includes(nodeID),
`Runtime must not reference aggregate Figma node ${nodeID}`
);
}
for (const forbidden of [
'figma-827-3011.png',
'.audit/figma',
'whole-page',
'page-screenshot',
'root-frame.png',
]) {
invariant(
!runtimeLower.includes(forbidden),
`Forbidden whole-page reference: ${forbidden}`
);
}
for (const asset of manifest.assets) {
invariant(
runtimeSource.includes(asset.file),
`${asset.file}: traced asset is not consumed by the 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('data-hy-app-code="fami"'),
'Fami app identity is required'
);
invariant(
html.indexOf("window.HyAppDefaultAppCode = 'fami'") <
html.indexOf('../common/api.js'),
'Fami app code must be configured before common API initialization'
);
invariant(
html.includes('<template id="sellerCardTemplate">'),
'Seller cards must be produced from a DOM template'
);
invariant(
!runtimeLower.includes('home-indicator') &&
!runtimeLower.includes('status-bar') &&
!runtimeLower.includes('bottom-bar'),
'The Figma frame has no fake system bars or bottom bar'
);
const walletStart = api.indexOf('var walletAPI =');
const walletEnd = api.indexOf('var pointWalletAPI =', walletStart);
const walletSource = api.slice(walletStart, walletEnd);
invariant(
walletSource.includes('listCoinSellers: function ()'),
'wallet.listCoinSellers() is required'
);
invariant(
walletSource.includes("request('/api/v1/wallet/coin-sellers'") &&
!walletSource.includes(
"'/api/v1/wallet/coin-sellers', {\n method: 'GET',\n query:"
),
'Coin seller list must use the exact region-scoped GET endpoint without query data'
);
invariant(
js.includes('window.HyAppAPI.wallet.listCoinSellers()'),
'The page must consume the common Coin Seller API wrapper'
);
invariant(
js.includes("params.get('mock') === '1'"),
'Visual fixtures must require explicit mock=1'
);
invariant(
js.includes('if (sequence !== requestSequence) return;'),
'Async retries must reject stale responses'
);
invariant(
js.includes('source.country_code') &&
js.includes('source.whatsapp_url') &&
js.includes('source.display_user_id'),
'Dynamic API field mapping is incomplete'
);
invariant(
js.includes("if (code === 'AE')") && js.includes('flagcdn.com/w80/'),
'AE fallback must not be reused for other country codes'
);
invariant(
css.includes('aspect-ratio: 1008 / 183') &&
css.includes('margin-top: -7.5cqw') &&
css.includes('top: 33.8798%') &&
css.includes('#773924') &&
css.includes('#000 83.8%') &&
css.includes('transparent 94.7%'),
'Measured Figma geometry or styling is missing'
);
invariant(
css.includes('prefers-reduced-motion'),
'Loading motion must respect reduced motion'
);
for (const locale of locales) {
invariant(
locale.includes('"coin_seller_fami.page_title"') &&
locale.includes('"coin_seller_fami.contact_unavailable"'),
'Required Coin Seller translations are missing'
);
}
console.log(
`coin-seller/fami.html asset audit passed (${manifest.assets.length} traced layers)`
);