170 lines
7.3 KiB
JavaScript
170 lines
7.3 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 branding 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',
|
|
description: '一个匿名访问、自托管的浏览器本地工具箱',
|
|
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('declares one product description for standard and Open Graph consumers without a custom image', async () => {
|
|
const html = (await readProjectFile('index.html')).toString('utf8')
|
|
|
|
expect(html).toContain('<meta name="description" content="一个匿名访问、自托管的浏览器本地工具箱" />')
|
|
expect(html).toContain('<meta property="og:description" content="一个匿名访问、自托管的浏览器本地工具箱" />')
|
|
expect(html.match(/一个匿名访问、自托管的浏览器本地工具箱/g)).toHaveLength(2)
|
|
expect(html).not.toContain('share-preview.png')
|
|
expect(html).not.toMatch(/(?:name|property)="(?:og|twitter):[^\"]*image/i)
|
|
expect(html).not.toMatch(/(?:name|property)="twitter:/i)
|
|
})
|
|
|
|
it('includes public assets in container builds', async () => {
|
|
expect((await readProjectFile('Dockerfile')).toString('utf8')).toContain('COPY public ./public')
|
|
})
|
|
})
|