d3ro-voice/scripts/ci/verify-play-store-assets.mjs

765 lines
28 KiB
JavaScript

import { createHash } from 'node:crypto'
import {
cpSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, dirname, join, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { inflateSync } from 'node:zlib'
const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
const featureAssetDirectoryRelative = 'docs/v3/play/assets/feature-graphic'
const mobileIconManifestRelative = 'docs/v3/play/assets/mobile-icon-manifest.json'
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
const androidLegacyDensities = new Map([
['mdpi', 48],
['hdpi', 72],
['xhdpi', 96],
['xxhdpi', 144],
['xxxhdpi', 192]
])
const requiredIosSlots = [
['iphone', '20x20', '2x', 'AppIcon-iphone-20@2x.png', 40],
['iphone', '20x20', '3x', 'AppIcon-iphone-20@3x.png', 60],
['iphone', '29x29', '2x', 'AppIcon-iphone-29@2x.png', 58],
['iphone', '29x29', '3x', 'AppIcon-iphone-29@3x.png', 87],
['iphone', '40x40', '2x', 'AppIcon-iphone-40@2x.png', 80],
['iphone', '40x40', '3x', 'AppIcon-iphone-40@3x.png', 120],
['iphone', '60x60', '2x', 'AppIcon-iphone-60@2x.png', 120],
['iphone', '60x60', '3x', 'AppIcon-iphone-60@3x.png', 180],
['ipad', '20x20', '1x', 'AppIcon-ipad-20@1x.png', 20],
['ipad', '20x20', '2x', 'AppIcon-ipad-20@2x.png', 40],
['ipad', '29x29', '1x', 'AppIcon-ipad-29@1x.png', 29],
['ipad', '29x29', '2x', 'AppIcon-ipad-29@2x.png', 58],
['ipad', '40x40', '1x', 'AppIcon-ipad-40@1x.png', 40],
['ipad', '40x40', '2x', 'AppIcon-ipad-40@2x.png', 80],
['ipad', '76x76', '1x', 'AppIcon-ipad-76@1x.png', 76],
['ipad', '76x76', '2x', 'AppIcon-ipad-76@2x.png', 152],
['ipad', '83.5x83.5', '2x', 'AppIcon-ipad-83.5@2x.png', 167],
['ios-marketing', '1024x1024', '1x', 'AppIcon-marketing-1024@1x.png', 1024]
]
function fail(code) {
throw new Error(`play_store_asset_verification_failed:${code}`)
}
function assert(condition, code) {
if (!condition) fail(code)
}
function comparablePath(path) {
return process.platform === 'win32' ? path.toLowerCase() : path
}
function resolveInside(baseDirectory, relativePath, code = 'invalid_asset_path') {
assert(typeof relativePath === 'string' && relativePath.length > 0, 'invalid_asset_path')
const absolutePath = resolve(baseDirectory, relativePath)
const comparableBase = comparablePath(baseDirectory)
const comparableAbsolute = comparablePath(absolutePath)
assert(
comparableAbsolute.startsWith(`${comparableBase}${sep}`),
`${code}_outside_allowed_directory`
)
return absolutePath
}
function sha256(buffer) {
return createHash('sha256').update(buffer).digest('hex').toUpperCase()
}
function readRequired(baseDirectory, relativePath, code) {
const absolutePath = resolveInside(baseDirectory, relativePath, code)
try {
const file = readFileSync(absolutePath)
assert(statSync(absolutePath).isFile(), `${code}_not_file`)
return { absolutePath, file }
} catch (error) {
if (error?.code === 'ENOENT') fail(`${code}_missing`)
throw error
}
}
function readJson(baseDirectory, relativePath, code) {
const { file } = readRequired(baseDirectory, relativePath, code)
try {
return JSON.parse(file.toString('utf8'))
} catch {
fail(`${code}_invalid_json`)
}
}
function readPngHeader(buffer, filename) {
assert(buffer.length >= 33, `truncated_png_${filename}`)
assert(buffer.subarray(0, 8).equals(pngSignature), `invalid_png_signature_${filename}`)
assert(buffer.readUInt32BE(8) === 13, `invalid_ihdr_length_${filename}`)
assert(buffer.subarray(12, 16).toString('ascii') === 'IHDR', `missing_ihdr_${filename}`)
return {
width: buffer.readUInt32BE(16),
height: buffer.readUInt32BE(20),
bitDepth: buffer[24],
colorType: buffer[25],
compression: buffer[26],
filter: buffer[27],
interlace: buffer[28]
}
}
function parsePng(buffer, filename) {
const header = readPngHeader(buffer, filename)
const idat = []
let hasTransparencyChunk = false
let sawIend = false
let offset = 8
while (offset < buffer.length) {
assert(offset + 12 <= buffer.length, `truncated_png_chunk_${filename}`)
const length = buffer.readUInt32BE(offset)
const type = buffer.subarray(offset + 4, offset + 8).toString('ascii')
const dataStart = offset + 8
const dataEnd = dataStart + length
assert(dataEnd + 4 <= buffer.length, `truncated_png_chunk_${filename}`)
if (type === 'IDAT') idat.push(buffer.subarray(dataStart, dataEnd))
if (type === 'tRNS') hasTransparencyChunk = true
offset = dataEnd + 4
if (type === 'IEND') {
sawIend = true
break
}
}
assert(sawIend, `missing_png_iend_${filename}`)
return { ...header, idat, hasTransparencyChunk }
}
function paethPredictor(left, up, upperLeft) {
const predictor = left + up - upperLeft
const leftDistance = Math.abs(predictor - left)
const upDistance = Math.abs(predictor - up)
const upperLeftDistance = Math.abs(predictor - upperLeft)
if (leftDistance <= upDistance && leftDistance <= upperLeftDistance) return left
if (upDistance <= upperLeftDistance) return up
return upperLeft
}
function rgba8AlphaRange(png, filename) {
assert(png.bitDepth === 8 && png.colorType === 6, `unsupported_alpha_png_${filename}`)
assert(png.compression === 0 && png.filter === 0, `unsupported_png_encoding_${filename}`)
assert(png.interlace === 0, `interlaced_png_${filename}`)
assert(png.idat.length > 0, `missing_png_idat_${filename}`)
const bytesPerPixel = 4
const rowBytes = png.width * bytesPerPixel
const inflated = inflateSync(Buffer.concat(png.idat))
assert(inflated.length === (rowBytes + 1) * png.height, `unexpected_png_data_length_${filename}`)
let minimum = 255
let maximum = 0
let previousRow = Buffer.alloc(rowBytes)
let inputOffset = 0
for (let y = 0; y < png.height; y += 1) {
const filterType = inflated[inputOffset]
inputOffset += 1
assert(filterType <= 4, `invalid_png_filter_${filename}`)
const row = Buffer.allocUnsafe(rowBytes)
for (let x = 0; x < rowBytes; x += 1) {
const raw = inflated[inputOffset + x]
const left = x >= bytesPerPixel ? row[x - bytesPerPixel] : 0
const up = previousRow[x]
const upperLeft = x >= bytesPerPixel ? previousRow[x - bytesPerPixel] : 0
let value = raw
if (filterType === 1) value += left
if (filterType === 2) value += up
if (filterType === 3) value += Math.floor((left + up) / 2)
if (filterType === 4) value += paethPredictor(left, up, upperLeft)
row[x] = value & 0xff
}
for (let x = 3; x < rowBytes; x += bytesPerPixel) {
minimum = Math.min(minimum, row[x])
maximum = Math.max(maximum, row[x])
}
inputOffset += rowBytes
previousRow = row
}
return { minimum, maximum }
}
function verifyPng(baseDirectory, relativePath, expected, code) {
const { file } = readRequired(baseDirectory, relativePath, code)
assert(sha256(file) === expected.sha256, `${code}_hash`)
const png = parsePng(file, basename(relativePath))
assert(png.width === expected.width, `${code}_width`)
assert(png.height === expected.height, `${code}_height`)
assert(png.bitDepth === expected.bitDepth, `${code}_bit_depth`)
assert(png.colorType === expected.colorType, `${code}_color_type`)
if (expected.alpha === 'opaque') {
if (png.colorType === 6) {
const alpha = rgba8AlphaRange(png, basename(relativePath))
assert(alpha.minimum === 255 && alpha.maximum === 255, `${code}_not_opaque`)
} else {
assert(!png.hasTransparencyChunk, `${code}_transparency_chunk`)
}
}
if (expected.alpha === 'has_transparency') {
const alpha = rgba8AlphaRange(png, basename(relativePath))
assert(alpha.minimum < 255 && alpha.maximum === 255, `${code}_transparency_range`)
}
if (expected.alpha === 'no_alpha_channel') {
assert(png.colorType !== 4 && png.colorType !== 6, `${code}_alpha_channel`)
assert(!png.hasTransparencyChunk, `${code}_transparency_chunk`)
}
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function verifySvg(baseDirectory, asset, code) {
const { file } = readRequired(baseDirectory, asset.path, code)
assert(sha256(file) === asset.sha256, `${code}_hash`)
const text = file.toString('utf8')
assert(/<svg\b/.test(text), `${code}_not_svg`)
assert(new RegExp(`viewBox=["']${escapeRegExp(asset.viewBox)}["']`).test(text), `${code}_viewbox`)
}
function verifyFeatureGraphics(root) {
const assetDirectory = resolveInside(
root,
featureAssetDirectoryRelative,
'feature_asset_directory'
)
const manifest = readJson(assetDirectory, 'manifest.json', 'feature_manifest')
assert(manifest.schemaVersion === 2, 'manifest_schema')
assert(Array.isArray(manifest.candidates) && manifest.candidates.length === 4, 'candidate_count')
assert(manifest.selection?.status === 'selected_console_preview_verified', 'selection_status')
const candidateIds = new Set()
for (const candidate of manifest.candidates) {
assert(!candidateIds.has(candidate.id), `duplicate_candidate_${candidate.id}`)
candidateIds.add(candidate.id)
const source = readRequired(assetDirectory, candidate.source, `source_${candidate.id}`)
const output = readRequired(assetDirectory, candidate.output, `output_${candidate.id}`)
assert(
statSync(source.absolutePath).size === candidate.sourceBytes,
`source_size_${candidate.id}`
)
assert(
statSync(output.absolutePath).size === candidate.outputBytes,
`output_size_${candidate.id}`
)
assert(sha256(source.file) === candidate.sourceSha256, `source_hash_${candidate.id}`)
assert(sha256(output.file) === candidate.outputSha256, `output_hash_${candidate.id}`)
const sourceText = source.file.toString('utf8')
assert(/<svg\b/.test(sourceText), `source_not_svg_${candidate.id}`)
assert(/viewBox="0 0 1024 500"/.test(sourceText), `source_viewbox_${candidate.id}`)
const png = readPngHeader(output.file, candidate.output)
assert(png.width === manifest.export.width, `png_width_${candidate.id}`)
assert(png.height === manifest.export.height, `png_height_${candidate.id}`)
assert(png.bitDepth === manifest.export.bitDepth, `png_bit_depth_${candidate.id}`)
assert(png.colorType === manifest.export.pngColorType, `png_color_type_${candidate.id}`)
}
const canonical = manifest.canonical
assert(canonical?.id === manifest.selection.recommended, 'canonical_selection')
assert(!candidateIds.has(canonical.id), 'canonical_id_conflict')
const canonicalSource = readRequired(assetDirectory, canonical.source, 'feature_canonical_source')
const canonicalPrompt = readRequired(assetDirectory, canonical.prompt, 'feature_canonical_prompt')
const canonicalOutput = readRequired(assetDirectory, canonical.output, 'feature_canonical_output')
assert(
statSync(canonicalSource.absolutePath).size === canonical.sourceBytes,
'feature_canonical_source_size'
)
assert(
statSync(canonicalPrompt.absolutePath).size === canonical.promptBytes,
'feature_canonical_prompt_size'
)
assert(
statSync(canonicalOutput.absolutePath).size === canonical.outputBytes,
'feature_canonical_output_size'
)
assert(sha256(canonicalSource.file) === canonical.sourceSha256, 'feature_canonical_source_hash')
assert(sha256(canonicalPrompt.file) === canonical.promptSha256, 'feature_canonical_prompt_hash')
verifyPng(
assetDirectory,
canonical.output,
{
sha256: canonical.outputSha256,
width: manifest.export.width,
height: manifest.export.height,
bitDepth: manifest.export.bitDepth,
colorType: manifest.export.pngColorType,
alpha: 'no_alpha_channel'
},
'feature_canonical'
)
return {
candidateCount: manifest.candidates.length,
canonicalId: canonical.id
}
}
function verifyAndroidIcons(root, android) {
assert(android.icon === '@mipmap/ic_launcher', 'android_icon_contract')
assert(android.roundIcon === '@mipmap/ic_launcher_round', 'android_round_icon_contract')
const manifest = readRequired(
root,
android.applicationManifest,
'android_manifest'
).file.toString('utf8')
assert(
new RegExp(`android:icon\\s*=\\s*["']${escapeRegExp(android.icon)}["']`).test(manifest),
'android_manifest_icon'
)
assert(
new RegExp(`android:roundIcon\\s*=\\s*["']${escapeRegExp(android.roundIcon)}["']`).test(
manifest
),
'android_manifest_round_icon'
)
const colors = readRequired(
root,
android.background.path,
'android_launcher_colors'
).file.toString('utf8')
const backgroundPattern = new RegExp(
`<color\\s+name=["']${escapeRegExp(android.background.resource)}["']\\s*>\\s*${escapeRegExp(android.background.value)}\\s*</color>`,
'i'
)
assert(backgroundPattern.test(colors), 'android_launcher_background')
assert(
Array.isArray(android.sourceAssets) && android.sourceAssets.length === 2,
'android_source_count'
)
android.sourceAssets.forEach((asset, index) => verifySvg(root, asset, `android_source_${index}`))
for (const [role, expectedPath] of [
['foreground', 'apps/mobile-rn/android/app/src/main/res/drawable/d3ro_launcher_foreground.xml'],
['monochrome', 'apps/mobile-rn/android/app/src/main/res/drawable/d3ro_launcher_monochrome.xml']
]) {
const vector = android.vectors?.[role]
assert(vector?.path === expectedPath, `android_${role}_vector_path`)
const { file } = readRequired(root, vector.path, `android_${role}_vector`)
assert(sha256(file) === vector.sha256, `android_${role}_vector_hash`)
const text = file.toString('utf8')
assert(/<vector\b/.test(text), `android_${role}_vector_root`)
assert(/android:viewportWidth="108"/.test(text), `android_${role}_vector_width`)
assert(/android:viewportHeight="108"/.test(text), `android_${role}_vector_height`)
}
assert(
Array.isArray(android.adaptiveIcons) && android.adaptiveIcons.length === 4,
'android_adaptive_icon_count'
)
const requiredAdaptivePaths = new Map([
['apps/mobile-rn/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml', false],
['apps/mobile-rn/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml', false],
['apps/mobile-rn/android/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml', true],
['apps/mobile-rn/android/app/src/main/res/mipmap-anydpi-v33/ic_launcher_round.xml', true]
])
const adaptivePaths = new Set()
for (const adaptive of android.adaptiveIcons) {
assert(!adaptivePaths.has(adaptive.path), 'android_adaptive_icon_duplicate')
adaptivePaths.add(adaptive.path)
assert(requiredAdaptivePaths.has(adaptive.path), 'android_adaptive_icon_path')
assert(
adaptive.monochrome === requiredAdaptivePaths.get(adaptive.path),
'android_adaptive_icon_api_contract'
)
const text = readRequired(root, adaptive.path, 'android_adaptive_icon').file.toString('utf8')
assert(/<adaptive-icon\b/.test(text), 'android_adaptive_icon_root')
assert(
/<background\s+android:drawable="@color\/d3ro_launcher_background"\s*\/>/.test(text),
'android_adaptive_icon_background'
)
assert(
/<foreground\s+android:drawable="@drawable\/d3ro_launcher_foreground"\s*\/>/.test(text),
'android_adaptive_icon_foreground'
)
const hasMonochrome =
/<monochrome\s+android:drawable="@drawable\/d3ro_launcher_monochrome"\s*\/>/.test(text)
assert(hasMonochrome === adaptive.monochrome, 'android_adaptive_icon_monochrome')
assert(adaptive.path.includes(adaptive.monochrome ? '-v33/' : '-v26/'), 'android_adaptive_api')
}
for (const requiredPath of requiredAdaptivePaths.keys()) {
assert(
adaptivePaths.has(requiredPath),
`android_adaptive_icon_missing_${basename(requiredPath)}`
)
}
assert(
Array.isArray(android.legacyIcons) &&
android.legacyIcons.length === androidLegacyDensities.size,
'android_legacy_icon_count'
)
const densities = new Set()
for (const legacy of android.legacyIcons) {
const expectedPixels = androidLegacyDensities.get(legacy.density)
assert(expectedPixels !== undefined, `android_density_${legacy.density}`)
assert(!densities.has(legacy.density), `android_density_duplicate_${legacy.density}`)
densities.add(legacy.density)
assert(legacy.pixels === expectedPixels, `android_density_pixels_${legacy.density}`)
const basePath = `apps/mobile-rn/android/app/src/main/res/mipmap-${legacy.density}`
assert(legacy.iconPath === `${basePath}/ic_launcher.png`, `android_icon_path_${legacy.density}`)
assert(
legacy.roundPath === `${basePath}/ic_launcher_round.png`,
`android_round_path_${legacy.density}`
)
const expected = {
width: expectedPixels,
height: expectedPixels,
bitDepth: 8,
colorType: 6,
alpha: 'has_transparency'
}
verifyPng(
root,
legacy.iconPath,
{ ...expected, sha256: legacy.iconSha256 },
`android_icon_${legacy.density}`
)
verifyPng(
root,
legacy.roundPath,
{ ...expected, sha256: legacy.roundSha256 },
`android_round_icon_${legacy.density}`
)
}
}
function slotKey(icon) {
return `${icon.idiom}|${icon.size}|${icon.scale}`
}
function verifyIosIcons(root, ios) {
assert(
ios.source === 'docs/v3/play/assets/d3ro-voice-play-icon-source.svg',
'ios_canonical_source'
)
assert(JSON.stringify(ios.targetedDeviceFamilies) === '[1,2]', 'ios_targeted_families_contract')
assert(
ios.bitDepth === 8 && ios.colorType === 2 && ios.alphaChannel === false,
'ios_png_contract'
)
assert(Array.isArray(ios.icons) && ios.icons.length === requiredIosSlots.length, 'ios_icon_count')
const requiredBySlot = new Map(
requiredIosSlots.map(([idiom, size, scale, filename, pixels]) => [
`${idiom}|${size}|${scale}`,
{ idiom, size, scale, filename, pixels }
])
)
const manifestBySlot = new Map()
for (const icon of ios.icons) {
const key = slotKey(icon)
assert(!manifestBySlot.has(key), `ios_icon_duplicate_${key}`)
manifestBySlot.set(key, icon)
const required = requiredBySlot.get(key)
assert(required, `ios_icon_unexpected_${key}`)
assert(icon.filename === required.filename, `ios_icon_filename_${key}`)
assert(icon.pixels === required.pixels, `ios_icon_pixels_${key}`)
assert(
typeof icon.sha256 === 'string' && icon.sha256.length === 64,
`ios_icon_hash_contract_${key}`
)
}
for (const key of requiredBySlot.keys()) {
assert(manifestBySlot.has(key), `ios_icon_missing_${key}`)
}
const contents = readJson(root, ios.contents, 'ios_appicon_contents')
assert(contents.info?.author === 'xcode' && contents.info?.version === 1, 'ios_contents_info')
assert(
Array.isArray(contents.images) && contents.images.length === requiredIosSlots.length,
'ios_contents_count'
)
const contentsBySlot = new Map()
for (const entry of contents.images) {
const key = slotKey(entry)
assert(!contentsBySlot.has(key), `ios_contents_duplicate_${key}`)
contentsBySlot.set(key, entry)
const icon = manifestBySlot.get(key)
assert(icon, `ios_contents_unexpected_${key}`)
assert(entry.filename === icon.filename, `ios_contents_filename_${key}`)
}
for (const key of manifestBySlot.keys()) {
assert(contentsBySlot.has(key), `ios_contents_missing_${key}`)
}
const contentsDirectory = dirname(resolveInside(root, ios.contents, 'ios_contents_path'))
const expectedPngFiles = new Set(ios.icons.map((icon) => icon.filename))
const actualPngFiles = new Set(
readdirSync(contentsDirectory).filter((name) => name.endsWith('.png'))
)
assert(actualPngFiles.size === expectedPngFiles.size, 'ios_unassigned_png_count')
for (const filename of actualPngFiles) {
assert(expectedPngFiles.has(filename), `ios_unassigned_png_${filename}`)
}
for (const icon of ios.icons) {
assert(basename(icon.filename) === icon.filename, `ios_icon_filename_path_${icon.filename}`)
verifyPng(
contentsDirectory,
icon.filename,
{
width: icon.pixels,
height: icon.pixels,
bitDepth: ios.bitDepth,
colorType: ios.colorType,
alpha: 'no_alpha_channel',
sha256: icon.sha256
},
`ios_icon_${icon.idiom}_${icon.size}_${icon.scale}`
)
}
const project = readRequired(root, ios.project, 'ios_xcode_project').file.toString('utf8')
assert(
(project.match(/ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;/g) ?? []).length >= 2,
'ios_xcode_appicon_binding'
)
assert(
(project.match(/TARGETED_DEVICE_FAMILY = "1,2";/g) ?? []).length >= 2,
'ios_xcode_targeted_families'
)
}
function verifyMobileIcons(root) {
const manifest = readJson(root, mobileIconManifestRelative, 'mobile_icon_manifest')
assert(manifest.schemaVersion === 1, 'mobile_icon_manifest_schema')
assert(
manifest.canonicalSource?.path === 'docs/v3/play/assets/d3ro-voice-play-icon-source.svg',
'canonical_icon_source_path'
)
verifySvg(root, manifest.canonicalSource, 'canonical_icon_source')
assert(
manifest.playStoreIcon?.path === 'docs/v3/play/assets/d3ro-voice-play-icon-512.png',
'play_icon_path'
)
verifyPng(root, manifest.playStoreIcon.path, manifest.playStoreIcon, 'play_icon')
verifyAndroidIcons(root, manifest.android)
verifyIosIcons(root, manifest.ios)
verifyPng(
root,
manifest.pixelLauncherProof.path,
{
...manifest.pixelLauncherProof,
bitDepth: 8,
colorType: 6,
alpha: 'opaque'
},
'pixel_launcher_proof'
)
return {
androidLegacyIconCount: manifest.android.legacyIcons.length * 2,
iosIconCount: manifest.ios.icons.length
}
}
function verifyAll(root, printSummary = true) {
const feature = verifyFeatureGraphics(root)
const mobile = verifyMobileIcons(root)
if (printSummary) {
console.log(
`Play store assets verified: ${feature.candidateCount} historical feature candidates + canonical ${feature.canonicalId}, canonical 512 icon, ${mobile.androidLegacyIconCount} Android legacy icons + adaptive/monochrome, ${mobile.iosIconCount} iOS/iPad icons, hashes and alpha contracts GREEN`
)
}
}
function copyFixture(relativePath, temporaryRoot) {
const source = resolveInside(workspaceRoot, relativePath, 'self_test_source')
const destination = resolveInside(temporaryRoot, relativePath, 'self_test_destination')
mkdirSync(dirname(destination), { recursive: true })
cpSync(source, destination, { recursive: true })
}
function createSelfTestFixture() {
const temporaryRoot = mkdtempSync(join(tmpdir(), 'd3ro-mobile-icon-assets-'))
for (const relativePath of [
'docs/v3/play/assets',
'apps/mobile-rn/android/app/src/main/AndroidManifest.xml',
'apps/mobile-rn/android/app/src/main/res',
'apps/mobile-rn/ios/D3ROVoice/Images.xcassets/AppIcon.appiconset',
'apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj'
]) {
copyFixture(relativePath, temporaryRoot)
}
return temporaryRoot
}
function expectSelfTestFailure(name, mutate, expectedCode) {
const temporaryRoot = createSelfTestFixture()
try {
mutate(temporaryRoot)
let failure
try {
verifyAll(temporaryRoot, false)
} catch (error) {
failure = error
}
assert(failure instanceof Error, `self_test_${name}_unexpected_green`)
assert(
failure.message === `play_store_asset_verification_failed:${expectedCode}`,
`self_test_${name}_wrong_failure_${failure.message}`
)
console.log(`Negative self-test ${name}: ${expectedCode} RED as expected`)
} finally {
rmSync(temporaryRoot, { recursive: true, force: true })
}
}
function runSelfTests() {
verifyAll(workspaceRoot, false)
expectSelfTestFailure(
'android_manifest_icon',
(root) => {
const path = resolveInside(
root,
'apps/mobile-rn/android/app/src/main/AndroidManifest.xml',
'self_test_android_manifest'
)
writeFileSync(
path,
readFileSync(path, 'utf8').replace('@mipmap/ic_launcher"', '@mipmap/missing"')
)
},
'android_manifest_icon'
)
expectSelfTestFailure(
'android_density_dimensions',
(root) => {
const manifestPath = resolveInside(
root,
mobileIconManifestRelative,
'self_test_mobile_manifest'
)
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
const hdpi = manifest.android.legacyIcons.find((icon) => icon.density === 'hdpi')
const mdpi = manifest.android.legacyIcons.find((icon) => icon.density === 'mdpi')
const mdpiBytes = readFileSync(resolveInside(root, mdpi.iconPath, 'self_test_mdpi_icon'))
writeFileSync(resolveInside(root, hdpi.iconPath, 'self_test_hdpi_icon'), mdpiBytes)
hdpi.iconSha256 = sha256(mdpiBytes)
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
},
'android_icon_hdpi_width'
)
expectSelfTestFailure(
'android_monochrome_missing',
(root) => {
unlinkSync(
resolveInside(
root,
'apps/mobile-rn/android/app/src/main/res/drawable/d3ro_launcher_monochrome.xml',
'self_test_monochrome'
)
)
},
'android_monochrome_vector_missing'
)
expectSelfTestFailure(
'ios_icon_missing',
(root) => {
unlinkSync(
resolveInside(
root,
'apps/mobile-rn/ios/D3ROVoice/Images.xcassets/AppIcon.appiconset/AppIcon-marketing-1024@1x.png',
'self_test_ios_marketing_icon'
)
)
},
'ios_unassigned_png_count'
)
expectSelfTestFailure(
'ios_contents_filename',
(root) => {
const path = resolveInside(
root,
'apps/mobile-rn/ios/D3ROVoice/Images.xcassets/AppIcon.appiconset/Contents.json',
'self_test_ios_contents'
)
const contents = JSON.parse(readFileSync(path, 'utf8'))
contents.images[0].filename = 'missing.png'
writeFileSync(path, `${JSON.stringify(contents, null, 2)}\n`)
},
'ios_contents_filename_iphone|20x20|2x'
)
expectSelfTestFailure(
'ios_alpha_channel',
(root) => {
const manifestPath = resolveInside(
root,
mobileIconManifestRelative,
'self_test_mobile_manifest'
)
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
const icon = manifest.ios.icons.find(
(candidate) =>
candidate.idiom === 'iphone' && candidate.size === '20x20' && candidate.scale === '2x'
)
const contentsDirectory = dirname(
resolveInside(root, manifest.ios.contents, 'self_test_ios_contents')
)
const path = resolveInside(contentsDirectory, icon.filename, 'self_test_ios_icon')
const bytes = readFileSync(path)
bytes[25] = 6
writeFileSync(path, bytes)
icon.sha256 = sha256(bytes)
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
},
'ios_icon_iphone_20x20_2x_color_type'
)
expectSelfTestFailure(
'feature_canonical_hash',
(root) => {
const manifestPath = resolveInside(
root,
`${featureAssetDirectoryRelative}/manifest.json`,
'self_test_feature_manifest'
)
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
const path = resolveInside(
root,
`${featureAssetDirectoryRelative}/${manifest.canonical.output}`,
'self_test_feature_canonical'
)
const bytes = readFileSync(path)
bytes[bytes.length - 1] ^= 0xff
writeFileSync(path, bytes)
},
'feature_canonical_hash'
)
console.log('Play/mobile icon negative self-tests GREEN: 7 fail-closed cases')
}
const argumentsSet = new Set(process.argv.slice(2))
for (const argument of argumentsSet) {
assert(argument === '--self-test', `unknown_argument_${argument}`)
}
if (argumentsSet.has('--self-test')) {
runSelfTests()
} else {
verifyAll(workspaceRoot)
}