test(web): remove native icon test decoding

This commit is contained in:
EricMoin authored and Stevring committed 2026-10-20 12:01:49 +08:00
1 parent 5a31bcc3e0
commit 63b3d2ce4d
7 files changed
+84 -78

No files matched your search

@@ -9,11 +9,11 @@
en: 726b732fcaf49153
zh: 74f854807a14409d
/agent-note-web-install-icons-and-launch-display/decision:
en: ac98a944eeedf562
zh: 2802162ddb00c1b0
en: a3417e4f8a4a7823
zh: 0e1bef4b86dd0bd4
/agent-note-web-install-icons-and-launch-display/alternatives-considered:
en: 49f105c70f8453c0
zh: aed4457cfe9d41df
/agent-note-web-install-icons-and-launch-display/consequences:
en: d0caf7aca43fa1db
zh: a354287e7aa1343d
en: 0083cf91a9a53d83
zh: 15ce227df95ca334
@@ -12,7 +12,7 @@ A launcher masks the icon it puts on the home screen and chooses that icon itsel
`apps/web/public/` carries four install icons: a 192px and a 512px `any` raster, a 512px `maskable` raster, and a 180px `apple-touch-icon`. [`render-app-icons.ts`](../../../../apps/web/scripts/render-app-icons.ts) renders all four from the official application tile in `apps/desktop/resources/icon.svg`, and `pnpm --filter @deepseek-ai/dsh-web-frontend run render:app-icons` regenerates the committed files. Every variant crops the tile's desktop drop shadow. The maskable variant scales the whale about the tile centre by `MASKABLE_ARTWORK_SCALE` while the tile's gradient keeps its own extent, so the border a mask may crop is still the real tile rather than a flat colour meeting it at a seam.
[`app-icons.spec.ts`](../../../../apps/web/tests/app-icons.spec.ts) measures the whale's ink radius and fails when it leaves the circle a platform mask is guaranteed to show; the unscaled rendering is its negative control, so the assertion cannot pass vacuously.
[`pwa-manifest.e2e.ts`](../../../../apps/web/tests/pwa-manifest.e2e.ts) uses the browser image decoder to measure the committed maskable whale's ink radius and fails when it leaves the circle a platform mask is guaranteed to show; the unscaled rendering is its negative control, so the assertion cannot pass vacuously.
The manifest requests `display: "standalone"`, names `#ffffff` as its `theme_color` and `background_color`, and lists the three rasters. `apps/web/index.html` adds the `apple-touch-icon` link, `mobile-web-app-capable`, and `apple-mobile-web-app-title`. It adds no `theme-color`: `ThemePresenter` owns the single such node and follows the resolved in-application theme, which a static media query cannot. The tab favicons are unchanged.
@@ -32,7 +32,7 @@ This supersedes the icon entry and the display mode in the [archived Web install
## Consequences
The repository carries four committed rasters, all rendered by one script from `apps/desktop/resources/icon.svg`. Changing the desktop tile means rerunning that script and reading the spec's diff.
The repository carries four committed rasters, all rendered by one script from `apps/desktop/resources/icon.svg`. Changing the desktop tile means rerunning that script and reviewing the PNG diff. Unit tests inspect committed PNG dimensions without loading the native renderer. Browser tests check the committed maskable safe zone and opaque corners, with an unscaled SVG as the negative control. No automated check compares committed pixels with freshly generated output.
The manifest's `theme_color` is one value and cannot follow the in-application theme, so a user whose theme differs from the system scheme sees that value until `ThemePresenter` writes the synchronized node shortly after boot. The presenter stays the only owner of `meta[name="theme-color"]`: a second static node wins document order and desynchronizes surrounding browser chrome from the rendered palette, which `settings-chrome.e2e.ts` pins.
@@ -12,7 +12,7 @@ Status: implemented
`apps/web/public/` 承载四个安装图标:192px 与 512px 的 `any` 位图、512px 的 `maskable` 位图,以及 180px 的 `apple-touch-icon`。[`render-app-icons.ts`](../../../../apps/web/scripts/render-app-icons.ts) 从 `apps/desktop/resources/icon.svg` 的官方应用底板渲染这四个文件,`pnpm --filter @deepseek-ai/dsh-web-frontend run render:app-icons` 重新生成已提交的文件。每个变体都会裁掉底板的桌面投影。maskable 变体围绕底板中心按 `MASKABLE_ARTWORK_SCALE` 缩放鲸鱼,而底板渐变保持自身范围,因此可能被蒙层裁掉的那圈边框仍是真实底板,而不是与其相接的纯色。
[`app-icons.spec.ts`](../../../../apps/web/tests/app-icons.spec.ts) 测量鲸鱼墨迹半径,一旦它越出平台蒙层保证显示的范围就失败;未缩放的渲染结果是它的反向对照,因此该断言不可能空泛通过。
[`pwa-manifest.e2e.ts`](../../../../apps/web/tests/pwa-manifest.e2e.ts) 使用浏览器图像解码器测量已提交 maskable 图标的鲸鱼墨迹半径,一旦它越出平台蒙层保证显示的范围就失败;未缩放的渲染结果是它的反向对照,因此该断言不可能空泛通过。
manifest 请求 `display: "standalone"`,把 `theme_color` 与 `background_color` 定为 `#ffffff`,并列出三个位图。`apps/web/index.html` 增加 `apple-touch-icon` 链接、`mobile-web-app-capable` 与 `apple-mobile-web-app-title`。它不添加 `theme-color`:该节点由 `ThemePresenter` 独占并跟随解析后的应用内主题,这是静态媒体查询做不到的。标签页 favicon 保持不变。
@@ -32,7 +32,7 @@ manifest 请求 `display: "standalone"`,把 `theme_color` 与 `background_colo
## Consequences
仓库承载四个已提交位图,全部由一个脚本从 `apps/desktop/resources/icon.svg` 渲染。底板变化意味着重跑该脚本并阅读 spec 的 diff。
仓库携带四张已提交的栅格图,均由同一个脚本从 `apps/desktop/resources/icon.svg` 渲染。桌面图块变化时,需要重新运行该脚本并审查 PNG diff。单元测试直接检查已提交 PNG 的尺寸,不加载原生渲染器。浏览器测试检查已提交 maskable 图标的安全区和不透明角落,并以未缩放 SVG 作为反向对照。没有自动检查将已提交像素与重新生成的结果进行比较。
manifest 的 `theme_color` 是单个值,无法跟随应用内主题;因此当用户选择的主题与系统方案不同时,该值会一直生效,直到 `ThemePresenter` 在启动后不久写入同步节点。该 presenter 仍是 `meta[name="theme-color"]` 的唯一所有者:第二个静态节点会在文档顺序上胜出,使周边浏览器界面与已渲染的配色脱节,这一点由 `settings-chrome.e2e.ts` 固定。
+2 -2
View File
@@ -23,7 +23,6 @@
import { readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'
/** Vector source owned by the desktop application; 1104 units square with the tile inset. */
export const TILE_SOURCE = fileURLToPath(new URL('../../desktop/resources/icon.svg', import.meta.url))
@@ -47,7 +46,7 @@ const SOURCE_DENSITY = 72
* Fraction of a maskable icon the artwork keeps: the platform may crop the
* border outside it. The tile's own ink reaches 0.461 of its edge from the
* centre, so 0.868 is the largest scale a circular mask still shows whole;
* `app-icons.spec.ts` re-derives this and fails when the artwork overflows.
* `pwa-manifest.e2e.ts` checks the committed artwork against that circle.
*/
export const MASKABLE_ARTWORK_SCALE = 0.82
@@ -142,6 +141,7 @@ async function renderPng(document: string, size: number): Promise<Buffer> {
// Render at the source scale the requested edge needs, then resize as a
// guarantee: the rasterizer must see the target density, not a large bitmap
// it would have to antialias down.
const { default: sharp } = await import('sharp')
return sharp(Buffer.from(document), { density: SOURCE_DENSITY * size / TILE_EDGE })
.resize(size, size)
.png()
+19 -62
View File
@@ -1,77 +1,34 @@
/**
* The install icons are committed binaries, so this suite renders them from the
* tile source and asserts the two properties a launcher depends on: every
* declared edge is really produced, and the maskable variant's artwork stays
* inside the circle a platform mask is guaranteed to show. Without the second
* the whale's tail is cropped on a round-masked home screen, which is the
* defect the dedicated icon set exists to remove.
*/
/** Committed install icon dimensions and the generator's SVG crop and safe-zone scale. */
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import sharp from 'sharp'
import {
APP_ICON_VARIANTS, MASKABLE_ARTWORK_SCALE, MASKABLE_SAFE_RADIUS_RATIO, TILE_SOURCE,
maskableDocument, renderAppIcons, tileDocument,
APP_ICON_VARIANTS, ICON_OUTPUT_DIRECTORY, MASKABLE_ARTWORK_SCALE, TILE_SOURCE,
maskableDocument, tileDocument,
} from '../scripts/render-app-icons.ts'
/** Channel sum above which a pixel is tile background rather than whale ink. */
const LIGHT_PIXEL_SUM = 384
/** Center-to-corner distance of the darkest pixel, in pixels, from the icon centre. */
async function inkRadius(png: Buffer): Promise<number> {
const { data, info } = await sharp(png).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
const centre = (info.width - 1) / 2
let radius = 0
for (let y = 0; y < info.height; y += 1) {
for (let x = 0; x < info.width; x += 1) {
const at = (y * info.width + x) * 4
const alpha = data[at + 3] ?? 0
const sum = (data[at] ?? 0) + (data[at + 1] ?? 0) + (data[at + 2] ?? 0)
if (alpha === 0 || sum >= LIGHT_PIXEL_SUM) continue
radius = Math.max(radius, Math.hypot(x - centre, y - centre))
}
}
return radius
}
/** The four corner pixels, where a maskable icon must be opaque. */
async function cornerAlphas(png: Buffer): Promise<number[]> {
const { data, info } = await sharp(png).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
const last = info.width - 1
return [[0, 0], [last, 0], [0, last], [last, last]].map(([x, y]) => data[((y ?? 0) * info.width + (x ?? 0)) * 4 + 3] ?? 0)
}
import { pngSize } from './png-size.ts'
describe('web install icons', () => {
it('renders every declared variant at its declared edge', async () => {
const icons = await renderAppIcons(await readFile(TILE_SOURCE, 'utf8'))
expect(icons.map(icon => icon.variant)).toEqual(APP_ICON_VARIANTS)
for (const { variant, png } of icons) {
const metadata = await sharp(png).metadata()
expect([variant.file, metadata.format, metadata.width, metadata.height])
.toEqual([variant.file, 'png', variant.size, variant.size])
it('ships every declared variant at its declared edge', async () => {
for (const variant of APP_ICON_VARIANTS) {
expect(pngSize(await readFile(join(ICON_OUTPUT_DIRECTORY, variant.file))))
.toEqual([variant.size, variant.size])
}
})
it('keeps the maskable artwork inside the circle a platform mask shows', async () => {
it('crops the desktop shadow and scales only the maskable artwork', async () => {
const source = await readFile(TILE_SOURCE, 'utf8')
const edge = 512
const maskable = (await renderAppIcons(source))
.find(icon => icon.variant.kind === 'maskable')
expect(maskable).toBeDefined()
const safeRadius = MASKABLE_SAFE_RADIUS_RATIO * edge
expect(await inkRadius(maskable!.png)).toBeLessThanOrEqual(safeRadius)
// Negative control: the same artwork without the maskable scale overflows
// the circle, so the assertion above is not vacuous.
const unscaled = await sharp(Buffer.from(tileDocument(source)), { density: 72 })
.resize(edge, edge).png().toBuffer()
expect(await inkRadius(unscaled)).toBeGreaterThan(safeRadius)
const tile = tileDocument(source)
const maskable = maskableDocument(source)
expect(tile).toContain('viewBox="40 32 1024 1024"')
expect(tile).not.toContain('<g filter="url(#filter0_d_1637_371)">')
expect(tile).not.toContain('scale(0.82)')
expect(maskable).toContain(`transform="translate(552 544) scale(${MASKABLE_ARTWORK_SCALE}) translate(-552 -544)"`)
})
it('fills a maskable icon to its edges so no mask meets transparency', async () => {
const maskable = maskableDocument(await readFile(TILE_SOURCE, 'utf8'))
const png = await sharp(Buffer.from(maskable), { density: 72 }).resize(512, 512).png().toBuffer()
expect(await cornerAlphas(png)).toEqual([255, 255, 255, 255])
it('rejects non-PNG bytes and incomplete image headers', () => {
expect(() => pngSize(Buffer.from('<svg/>'))).toThrow(/PNG IHDR/)
expect(() => pngSize(Buffer.alloc(33))).toThrow(/PNG IHDR/)
})
it('fails loudly on a source without the tile it transforms', () => {
+13
View File
@@ -0,0 +1,13 @@
/** PNG header inspection without a native image decoder. */
const PNG_IHDR = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82])
/**
* Read the dimensions of a PNG with its mandatory first IHDR chunk.
* @param png - encoded image bytes.
* @returns width and height in pixels.
* @throws when the PNG signature or IHDR chunk is missing.
*/
export function pngSize(png: Buffer): readonly [number, number] {
if (png.length < 33 || !png.subarray(0, PNG_IHDR.length).equals(PNG_IHDR)) throw new Error('Expected a PNG IHDR chunk')
return [png.readUInt32BE(16), png.readUInt32BE(20)]
}
+42 -6
View File
@@ -2,7 +2,9 @@ import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import sharp from 'sharp'
import { chromium } from 'playwright'
import { pngSize } from './png-size.ts'
import { TILE_SOURCE, tileDocument } from '../scripts/render-app-icons.ts'
const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url))
@@ -16,6 +18,7 @@ interface ManifestIcon {
it('ships install metadata with the built web application', async () => {
const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8')
expect(index).toContain('<meta name="viewport" content="width=device-width, initial-scale=1" />')
expect(index).toContain('<link rel="manifest" href="./manifest.webmanifest" />')
expect(index).toContain('<link rel="apple-touch-icon" href="./apple-touch-icon.png" />')
expect(index).toContain('<meta name="mobile-web-app-capable" content="yes" />')
@@ -56,12 +59,10 @@ it('ships every declared install icon as a raster of its declared size', async (
expect(manifest.icons).not.toHaveLength(0)
for (const icon of manifest.icons) {
const [edge] = icon.sizes.split('x')
const metadata = await sharp(await readFile(join(DIST_ROOT, icon.src))).metadata()
expect([icon.src, metadata.format, metadata.width, metadata.height])
.toEqual([icon.src, 'png', Number(edge), Number(edge)])
expect(pngSize(await readFile(join(DIST_ROOT, icon.src))))
.toEqual([Number(edge), Number(edge)])
}
const touch = await sharp(await readFile(join(DIST_ROOT, 'apple-touch-icon.png'))).metadata()
expect([touch.format, touch.width, touch.height]).toEqual(['png', 180, 180])
expect(pngSize(await readFile(join(DIST_ROOT, 'apple-touch-icon.png')))).toEqual([180, 180])
})
it('ships fixed-color favicons selected by document media queries', async () => {
@@ -75,3 +76,38 @@ it('ships fixed-color favicons selected by document media queries', async () =>
expect(dark).toContain('fill="#fff"')
expect(dark.replace('fill="#fff"', 'fill="#000"')).toBe(light)
})
it('keeps the shipped maskable artwork inside its opaque platform safe zone', async () => {
const browser = await chromium.launch({ headless: true })
try {
const page = await browser.newPage()
const maskable = await readFile(join(DIST_ROOT, 'icon-maskable-512.png'))
const tile = tileDocument(await readFile(TILE_SOURCE, 'utf8'))
const [scaled, unscaled] = await page.evaluate(async (sources) => {
return Promise.all(sources.map(async (source) => {
const image = new Image()
image.src = source
await image.decode()
const canvas = document.createElement('canvas')
canvas.width = canvas.height = 512
const context = canvas.getContext('2d')!
context.drawImage(image, 0, 0, 512, 512)
const { data } = context.getImageData(0, 0, 512, 512)
let radius = 0
for (let y = 0; y < 512; y += 1) {
for (let x = 0; x < 512; x += 1) {
const at = (y * 512 + x) * 4
if (data[at + 3] === 0 || data[at]! + data[at + 1]! + data[at + 2]! >= 384) continue
radius = Math.max(radius, Math.hypot(x - 255.5, y - 255.5))
}
}
return { radius, corners: [0, 511, 511 * 512, 512 * 512 - 1].map(index => data[index * 4 + 3]) }
}))
}, [`data:image/png;base64,${maskable.toString('base64')}`, `data:image/svg+xml;base64,${Buffer.from(tile).toString('base64')}`])
expect(scaled!.radius).toBeLessThanOrEqual(0.4 * 512)
expect(scaled!.corners).toEqual([255, 255, 255, 255])
expect(unscaled!.radius).toBeGreaterThan(0.4 * 512)
} finally {
await browser.close()
}
})