264 lines
8.8 KiB
JavaScript
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)`
|
|
);
|