Files
WebTools/tests/appIcons.test.mjs
T
2026-08-08 19:41:43 +08:00

158 lines
6.5 KiB
JavaScript

import { access, readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { inflateSync } from 'node:zlib'
import { describe, expect, it } from 'vitest'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const publicRoot = path.join(projectRoot, 'public')
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
async function readProjectFile(relativePath) {
return readFile(path.join(projectRoot, relativePath))
}
function pngDimensions(buffer) {
expect(buffer.subarray(0, pngSignature.length)).toEqual(pngSignature)
expect(buffer.subarray(12, 16).toString('ascii')).toBe('IHDR')
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
}
function paethPredictor(left, above, upperLeft) {
const estimate = left + above - upperLeft
const leftDistance = Math.abs(estimate - left)
const aboveDistance = Math.abs(estimate - above)
const upperLeftDistance = Math.abs(estimate - upperLeft)
if (leftDistance <= aboveDistance && leftDistance <= upperLeftDistance) return left
return aboveDistance <= upperLeftDistance ? above : upperLeft
}
function decodeRgbaPng(buffer) {
const { width, height } = pngDimensions(buffer)
expect(buffer[24]).toBe(8)
expect(buffer[25]).toBe(6)
const idatChunks = []
for (let offset = 8; offset < buffer.length;) {
const length = buffer.readUInt32BE(offset)
const type = buffer.subarray(offset + 4, offset + 8).toString('ascii')
if (type === 'IDAT') idatChunks.push(buffer.subarray(offset + 8, offset + 8 + length))
offset += 12 + length
}
const encoded = inflateSync(Buffer.concat(idatChunks))
const rowLength = width * 4
const pixels = Buffer.alloc(rowLength * height)
let encodedOffset = 0
for (let row = 0; row < height; row += 1) {
const filter = encoded[encodedOffset]
expect(filter).toBeLessThanOrEqual(4)
encodedOffset += 1
const rowOffset = row * rowLength
const previousRowOffset = rowOffset - rowLength
for (let column = 0; column < rowLength; column += 1) {
const left = column >= 4 ? pixels[rowOffset + column - 4] : 0
const above = row > 0 ? pixels[previousRowOffset + column] : 0
const upperLeft = row > 0 && column >= 4 ? pixels[previousRowOffset + column - 4] : 0
const predictor = filter === 1 ? left
: filter === 2 ? above
: filter === 3 ? Math.floor((left + above) / 2)
: filter === 4 ? paethPredictor(left, above, upperLeft)
: 0
pixels[rowOffset + column] = (encoded[encodedOffset] + predictor) & 0xff
encodedOffset += 1
}
}
expect(encodedOffset).toBe(encoded.length)
return { width, height, pixels }
}
describe('application icon assets', () => {
it('keeps the vector favicon self-contained', async () => {
const svg = await readFile(path.join(publicRoot, 'favicon.svg'), 'utf8')
expect(svg).toContain('viewBox="0 0 64 64"')
expect(svg).toContain('#1d2428')
expect(svg).toContain('stroke="#ffffff"')
expect(svg).not.toMatch(/<script\b|\bhref\s*=|\burl\s*\(/i)
})
it.each([
{ filename: 'apple-touch-icon.png', size: 180, opaque: true },
{ filename: 'icon-192.png', size: 192, opaque: false },
{ filename: 'icon-512.png', size: 512, opaque: false },
{ filename: 'icon-maskable-512.png', size: 512, opaque: true },
])('validates $filename dimensions, pixels and transparency', async ({ filename, size, opaque }) => {
const { width, height, pixels } = decodeRgbaPng(await readFile(path.join(publicRoot, filename)))
let minimumAlpha = 255
let maximumAlpha = 0
let hasLightArtwork = false
for (let offset = 0; offset < pixels.length; offset += 4) {
minimumAlpha = Math.min(minimumAlpha, pixels[offset + 3])
maximumAlpha = Math.max(maximumAlpha, pixels[offset + 3])
if (pixels[offset] > 240 && pixels[offset + 1] > 240 && pixels[offset + 2] > 240) hasLightArtwork = true
}
expect({ width, height }).toEqual({ width: size, height: size })
expect([minimumAlpha, maximumAlpha]).toEqual(opaque ? [255, 255] : [0, 255])
expect(hasLightArtwork).toBe(true)
})
it('packs 16, 32 and 48 pixel PNG frames into the ICO fallback', async () => {
const ico = await readFile(path.join(publicRoot, 'favicon.ico'))
expect(ico.readUInt16LE(0)).toBe(0)
expect(ico.readUInt16LE(2)).toBe(1)
expect(ico.readUInt16LE(4)).toBe(3)
const sizes = []
for (let index = 0; index < 3; index += 1) {
const entryOffset = 6 + index * 16
const width = ico[entryOffset] || 256
const height = ico[entryOffset + 1] || 256
const length = ico.readUInt32LE(entryOffset + 8)
const imageOffset = ico.readUInt32LE(entryOffset + 12)
const frame = ico.subarray(imageOffset, imageOffset + length)
expect(height).toBe(width)
expect(imageOffset + length).toBeLessThanOrEqual(ico.length)
expect(pngDimensions(frame)).toEqual({ width, height })
sizes.push(width)
}
expect(sizes).toEqual([16, 32, 48])
})
it('references complete install metadata and existing icon files', async () => {
const manifest = JSON.parse(await readFile(path.join(publicRoot, 'site.webmanifest'), 'utf8'))
expect(manifest).toMatchObject({
name: 'Web Tools',
short_name: 'Web Tools',
start_url: '/',
scope: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#f5f6f7',
})
expect(manifest.icons).toEqual([
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
])
await Promise.all(manifest.icons.map((icon) => access(path.join(publicRoot, icon.src.slice(1)))))
})
it('declares modern, legacy and Apple browser icon entry points', async () => {
const html = (await readProjectFile('index.html')).toString('utf8')
expect(html).toContain('<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />')
expect(html).toContain('<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="any" />')
expect(html).toContain('<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />')
expect(html).toContain('<link rel="manifest" href="/site.webmanifest" />')
})
it('includes public assets in container builds', async () => {
expect((await readProjectFile('Dockerfile')).toString('utf8')).toContain('COPY public ./public')
})
})