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 inviteDirectory = path.join(root, 'activity', 'invite'); const pageDirectory = path.join(inviteDirectory, 'huwaa'); const assetDirectory = path.join(pageDirectory, 'assets', 'layers'); const manifestPath = path.join(assetDirectory, 'manifest.json'); const forbiddenAggregateNodes = new Set([ '543:1998', '850:232', '850:431', '850:638', '850:237', '850:255', '850:262', '850:291', '850:314', '850:425', '850:628', '850:835', ]); 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({ default: '850:232', win: '850:431', estimatedRewards: '850:638', }), 'Entry-frame mapping changed' ); invariant( manifest.figma?.designSize?.width === 375 && manifest.figma?.designSize?.height === 1114, 'Unexpected design size' ); invariant(Array.isArray(manifest.assets), 'Manifest assets must be an array'); invariant(manifest.assets.length === 26, 'Manifest must describe 26 layers'); 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}: 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` ); for (const flag of [ 'containsText', 'containsControls', 'containsDynamicMedia', ]) { invariant( typeof asset[flag] === 'boolean', `${asset.file}: ${flag} must be boolean` ); } invariant(!asset.containsText, `${asset.file}: rendered text is forbidden`); invariant( !asset.containsControls, `${asset.file}: rendered controls are forbidden` ); invariant( !asset.containsDynamicMedia, `${asset.file}: dynamic business media 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'); 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 visual asset must have exactly one manifest entry' ); const [html, css, js, theme, api, locales] = await Promise.all([ readFile(path.join(inviteDirectory, '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', '850-232.png', '850-431.png', '850-638.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 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="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(api.includes('activityWheelAPI'), 'Wheel API is required'); invariant(js.includes('HyAppAPI.activityWheel'), 'Wheel API must be consumed'); invariant( /\.balances\(\s*['"]COIN['"]\s*\)/.test(js), 'Wallet balance API must be consumed' ); invariant( js.includes('/api/v1/users/me/invite-overview'), 'Invite overview API must be consumed' ); invariant( js.includes("params.get('mock') === '1'"), 'Visual fixtures must require an explicit mock query' ); invariant( !html.includes('DDDDD'), 'Figma placeholder reward names must not be baked into production HTML' ); invariant( html.includes('