增加完整的应用图标支持
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user