225 lines
7.1 KiB
JavaScript
225 lines
7.1 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 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('<span class="huwaa-task-status"'),
|
|
'Completed task status must be a label, not a button'
|
|
);
|
|
invariant(
|
|
html.includes('aria-live="polite"'),
|
|
'Win result must announce dynamic content'
|
|
);
|
|
invariant(
|
|
css.includes('prefers-reduced-motion'),
|
|
'Wheel animation must respect reduced motion'
|
|
);
|
|
|
|
for (const locale of locales) {
|
|
invariant(
|
|
locale.includes('"huwaa_invite.page_title"'),
|
|
'Required Huwaa invite translations are missing'
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
`activity/invite/huwaa.html asset audit passed (${manifest.assets.length} traced layers)`
|
|
);
|