import { createHash } from 'node:crypto'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import sharp from 'sharp'; import { test, expect } from '../../fixtures/base'; import { BRAND_LAUNCHER_BACKGROUND_COLOR, findMissingLauncherResources, findStockCapacitorResources, isBrandLauncherBackgroundColor, readAdaptiveIconBackgroundColor, REQUIRED_LAUNCHER_ICON_FILES, REQUIRED_SPLASH_FILES } from '../../../toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules'; /** * Regression coverage for: "No android app icon" - the Capacitor shell shipped * the stock Ionic placeholder launcher icon instead of the Toju brand mark. * * A native launcher icon cannot be asserted through a running browser, so this * spec verifies the committed Android resources directly: every density is * present, none still match a stock Capacitor placeholder, the adaptive-icon * background is the brand purple, and the generated bitmaps actually contain the * brand mark (white cat on a purple disc). This is deterministic - no emulator, * no timing - so it stays reliable in CI. */ const REPO_ROOT = join(__dirname, '..', '..', '..'); const RES_DIR = join(REPO_ROOT, 'toju-app', 'android', 'app', 'src', 'main', 'res'); const BRAND_PURPLE = { r: 0x4a, g: 0x21, b: 0x7a }; const WHITE = { r: 255, g: 255, b: 255 }; const COLOR_TOLERANCE = 24; function sha256(resRelativePath: string): string { return createHash('sha256').update(readFileSync(join(RES_DIR, resRelativePath))) .digest('hex'); } function colorDistance( left: { r: number; g: number; b: number }, right: { r: number; g: number; b: number } ): number { return Math.max(Math.abs(left.r - right.r), Math.abs(left.g - right.g), Math.abs(left.b - right.b)); } async function samplePixel( resRelativePath: string, xRatio: number, yRatio: number ): Promise<{ r: number; g: number; b: number }> { const { data, info } = await sharp(join(RES_DIR, resRelativePath)).raw() .toBuffer({ resolveWithObject: true }); const x = Math.min(info.width - 1, Math.floor(info.width * xRatio)); const y = Math.min(info.height - 1, Math.floor(info.height * yRatio)); const offset = (y * info.width + x) * info.channels; return { r: data[offset], g: data[offset + 1], b: data[offset + 2] }; } test.describe('Android brand app icon', () => { test('ships a launcher icon and splash for every required density', () => { const allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES]; const present = allRequired.filter((file) => existsSync(join(RES_DIR, file))); expect(findMissingLauncherResources(present)).toEqual([]); }); test('replaces every stock Capacitor placeholder with the brand asset', () => { const allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES]; const hashByFile = Object.fromEntries( allRequired.filter((file) => existsSync(join(RES_DIR, file))).map((file) => [file, sha256(file)]) ); expect(findStockCapacitorResources(hashByFile)).toEqual([]); }); test('uses the brand purple as the adaptive-icon background', () => { const valuesXml = readFileSync(join(RES_DIR, 'values', 'ic_launcher_background.xml'), 'utf8'); const color = readAdaptiveIconBackgroundColor(valuesXml); expect(color).not.toBe('#FFFFFF'); expect(isBrandLauncherBackgroundColor(color)).toBe(true); expect(color?.toLowerCase()).toBe(BRAND_LAUNCHER_BACKGROUND_COLOR.toLowerCase()); }); test('renders the brand mark (white cat on a purple disc) in the launcher bitmap', async () => { const launcher = 'mipmap-xxxhdpi/ic_launcher.png'; const ringTop = await samplePixel(launcher, 0.5, 0.12); const ringLeft = await samplePixel(launcher, 0.12, 0.5); const faceCenter = await samplePixel(launcher, 0.5, 0.5); expect(colorDistance(ringTop, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE); expect(colorDistance(ringLeft, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE); expect(colorDistance(faceCenter, WHITE)).toBeLessThanOrEqual(COLOR_TOLERANCE); }); test('renders the splash art as the brand mark centred on a purple field', async () => { const splash = 'drawable-port-xhdpi/splash.png'; const corner = await samplePixel(splash, 0.04, 0.04); const center = await samplePixel(splash, 0.5, 0.5); expect(colorDistance(corner, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE); expect(colorDistance(center, WHITE)).toBeLessThanOrEqual(COLOR_TOLERANCE); }); });