openpencil/tests/engine/render/canvas/cache.test.ts

218 lines
7 KiB
TypeScript
Raw Normal View History

import { beforeAll, describe, expect, setDefaultTimeout, test } from 'bun:test'
2026-05-18 15:41:01 +00:00
import { readFileSync } from 'node:fs'
import {
computeAllLayouts,
initCodec,
parseFigFile,
renderNodesToImage,
SkiaRenderer,
type SceneGraph,
type SceneNode
} from '@open-pencil/core'
import { initCanvasKit } from '#cli/headless'
import { expectDefined } from '#tests/helpers/assert'
import { repoPath } from '#tests/helpers/paths'
import { HEAVY_TEST_TIMEOUT_MS } from '#tests/helpers/test-utils'
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
import { expectRgbaPixels } from './helpers'
setDefaultTimeout(HEAVY_TEST_TIMEOUT_MS)
let graph: SceneGraph
let movingNodeId: string
let ck: Awaited<ReturnType<typeof initCanvasKit>>
beforeAll(async () => {
ck = await initCanvasKit()
await initCodec()
const buf = readFileSync(repoPath('tests/fixtures/gold-preview.fig'))
graph = await parseFigFile(buf.buffer as ArrayBuffer)
computeAllLayouts(graph)
const preview = [...graph.getAllNodes()].find((node) => node.name === 'Preview Thumbnail')
const input = preview
? graph.getChildren(preview.id).find((node) => node.name === 'Input')
: undefined
if (!input) throw new Error('gold-preview Input fixture node not found')
movingNodeId = input.id
fix(export): prevent GUID collisions and file corruption on .fig round-trip (#333) * fix(export): prevent GUID collisions and file corruption on .fig round-trip Nodes sharing the same imported source.id (component instance children, cloned subtrees) silently overwrote each other on export because the GUID assignment reused imported GUID values without checking for duplicates. This caused data loss on reimport — only the last node with a given GUID survived. - Track all assigned GUID values in a Set for O(1) collision detection. - Scan imported source.ids for both sessionID 0 and 1 before assigning any new GUIDs, so the counter starts past every imported value. - Fall back to counter-based GUIDs when source.id collides with an already-assigned value. Additional fixes in the same change set: - cloneTree now deep-copies source.fig via structuredClone, preventing mutations on a clone from corrupting the original node's kiwi payload. - Removed decompressFigKiwiData sync wrapper (zero callers) and the silent try/catch fallback in parseFigKiwiContainer that masked corrupt data as raw bytes. - buildFigKiwi uses Bun.zstdCompressSync when available, matching the zstd decompression path already used on import. - Fixed setSavedVersion ordering in read.ts — must run after requestRender to capture the post-bump version, preventing spurious dirty-state immediately after file reload. Tests: GUID collision (2 and 3 node), clone isolation, parse failure, text export zstd compatibility, gold-preview round-trip. * fix(export): handle EXCLUDE boolean operation and BOOLEAN_OPERATION node type The internal representation uses EXCLUDE for exclude boolean operations, but Figma's kiwi schema uses XOR. Map EXCLUDE back to XOR on export so round-trips through .fig files don't fail. Also add BOOLEAN_OPERATION to VALID_NODE_TYPES and increase timeout for heavy material3 fixture test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * style: format export-node.ts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test: add type guard after null assertion in guid-collision test Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(clipboard): detect zstd-compressed data before zlib inflate fflate's inflateSync silently accepts zstd-compressed data and returns garbage instead of throwing. Check for the zstd magic bytes (28 b5 2f fd) before attempting zlib decompression. Also revert the EXCLUDE enum addition to the kiwi schema since the export-node.ts mapping is sufficient. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(clipboard): add length guard before zstd magic byte check Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(scene-graph): group clone regression coverage * ci: retrigger CI checks * test: increase timeouts for heavy .fig fixture tests on slow CI runners Gold-preview.fig and material3.fig parsing/export tests consistently exceed the 5s bun:test default timeout on GitHub Actions runners. Increase to 30s for: beforeAll codec init, clipboard roundtrip, glyph blob roundtrip, group reclassification, and text measurement. * test: add individual test timeouts for heavy .fig fixture tests The beforeAll timeout helped but individual test() calls also need explicit 30s timeouts since bun:test applies the 5s default per-test. Fixes remaining CI flakes in glyph-blob roundtrip and clipboard roundtrip tests. * test: increase beforeAll timeout for render cache test The canvas/render cache test loads gold-preview.fig AND initializes CanvasKit (Skia WASM), which is much slower than the other fixture tests. Use 60s timeout to account for slow CI runners. * fix(export): reserve document GUID to prevent 0:0 namespace collision - Add docGuid (0:0) to assignedGuidValues before processing imported node source.ids, preventing an imported node with source.id "0:0" from reusing the document's GUID slot - Add regression test using session-0 source.ids to verify nodes survive roundtrip without document GUID collision - Remove unnecessary async keyword from synchronous component metadata test * fix(export): guard canvas GUID reuse with assignedGuidValues check - Mirror getOrCreateNodeGuid() collision logic in buildCanvasEntries(): if an imported page's source.id maps to a GUID already in assignedGuidValues, generate a fresh counter-based GUID instead - Prevents canvas-level last-write-wins when multiple pages share the same source.id or a page uses 0:0 * fix(test): use explicit little-endian writes and fix misleading test title - Replace host-endian Uint32Array writes with DataView.setUint32(offset, value, true) in parse-failures.test.ts to ensure platform-independent fig-kiwi container assembly - Rename test title from "clone clears source.id from the original" to "clone clears source.id from the clone" to accurately reflect what the assertions verify * test(io): use file-level timeout for heavy fixture --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-06-23 15:05:23 +00:00
}, 60_000)
function renderPreview(renderer: SkiaRenderer, sceneVersion: number): Uint8Array {
renderer.render(graph, new Set(), {}, sceneVersion)
const image = renderer.surface.makeImageSnapshot()
const pixels = image.readPixels(0, 0, {
width: 900,
height: 700,
colorType: ck.ColorType.RGBA_8888,
alphaType: ck.AlphaType.Unpremul,
colorSpace: ck.ColorSpace.SRGB
})
image.delete()
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
return expectRgbaPixels(pixels, 'rendered pixels')
}
function childNamed(parent: SceneNode | undefined, name: string): SceneNode | undefined {
return parent ? graph.getChildren(parent.id).find((node) => node.name === name) : undefined
}
function fixtureInputBadge(): SceneNode {
const preview = [...graph.getAllNodes()].find((node) => node.name === 'Preview Thumbnail')
const input = childNamed(preview, 'Input')
const inputRoot = childNamed(input, '_input')
const inputFrame = childNamed(inputRoot, 'Input')
const content = childNamed(inputFrame, 'Content')
const tags = childNamed(content, 'Tags')
const badge = childNamed(tags, 'Badge')
if (!badge) throw new Error('gold-preview badge fixture node not found')
return badge
}
function countDarkPixels(pixels: Uint8Array): number {
let dark = 0
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i + 3] > 200 && pixels[i] < 80 && pixels[i + 1] < 80 && pixels[i + 2] < 80) {
dark++
}
}
return dark
}
function pixelIndex(width: number, x: number, y: number): number {
return (y * width + x) * 4
}
function maskYCenter(
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
_pixels: Uint8Array,
width: number,
height: number,
matches: (index: number) => boolean,
xRange = [0, width]
): number {
let minY = Infinity
let maxY = -Infinity
for (let y = 0; y < height; y++) {
for (let x = xRange[0]; x < xRange[1]; x++) {
if (!matches(pixelIndex(width, x, y))) continue
minY = Math.min(minY, y)
maxY = Math.max(maxY, y)
}
}
if (minY === Infinity) throw new Error('pixel mask had no matches')
return (minY + maxY) / 2
}
describe('render cache regressions', () => {
test('badge label is vertically centered in the pill', async () => {
const surface = expectDefined(ck.MakeSurface(120, 60), 'badge surface')
const renderer = new SkiaRenderer(ck, surface)
await renderer.loadFonts()
try {
const badge = fixtureInputBadge()
const png = renderNodesToImage(ck, renderer, graph, graph.getPages()[0].id, [badge.id], {
scale: 1,
format: 'PNG'
})
expect(png).toBeTruthy()
const image = expectDefined(
ck.MakeImageFromEncoded(expectDefined(png, 'badge png')),
'badge image'
)
const width = image.width()
const height = image.height()
const pixels = image.readPixels(0, 0, {
width,
height,
colorType: ck.ColorType.RGBA_8888,
alphaType: ck.AlphaType.Unpremul,
colorSpace: ck.ColorSpace.SRGB
})
image.delete()
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
const renderedPixels = expectRgbaPixels(pixels, 'badge pixels')
const contentCenter = maskYCenter(
renderedPixels,
width,
height,
(i) => renderedPixels[i + 3] > 10
)
const textCenter = maskYCenter(
renderedPixels,
width,
height,
(i) =>
renderedPixels[i + 3] > 128 &&
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
renderedPixels[i] < 130 &&
renderedPixels[i + 1] < 140 &&
renderedPixels[i + 2] < 160,
[0, width]
)
expect(Math.abs(textCenter - contentCenter)).toBeLessThanOrEqual(0.6)
} finally {
surface.delete()
}
})
test('scene picture redraw keeps text after moving a node', async () => {
const surface = expectDefined(ck.MakeSurface(900, 700), 'preview surface')
const renderer = new SkiaRenderer(ck, surface)
renderer.viewportWidth = 900
renderer.viewportHeight = 700
renderer.dpr = 1
await renderer.loadFonts()
renderer.panX = 0
renderer.panY = 0
renderer.zoom = 0.75
renderer.pageId = graph.getPages()[0].id
try {
const beforeDark = countDarkPixels(renderPreview(renderer, 1))
const movingNode = graph.getNode(movingNodeId)
expect(movingNode).toBeDefined()
const originalX = movingNode?.x ?? 0
graph.updateNode(movingNodeId, { x: originalX + 20 })
expect(graph.getNode(movingNodeId)?.x).toBeCloseTo(originalX + 20, 3)
const afterDark = countDarkPixels(renderPreview(renderer, 2))
expect(afterDark).toBeGreaterThan(beforeDark * 0.8)
} finally {
surface.delete()
}
})
test('scene picture cache recovers after position preview commits', async () => {
const surface = expectDefined(ck.MakeSurface(900, 700), 'preview surface')
const renderer = new SkiaRenderer(ck, surface)
renderer.viewportWidth = 900
renderer.viewportHeight = 700
renderer.dpr = 1
renderer.panX = 0
renderer.panY = 0
renderer.zoom = 0.75
renderer.pageId = graph.getPages()[0].id
try {
renderPreview(renderer, 10)
expect(renderer.profiler.stats.scenePictureMode).toBe('record')
const movingNode = expectDefined(graph.getNode(movingNodeId), 'moving node')
const originalX = movingNode.x
graph.updateNodePositionPreview(movingNodeId, originalX + 20, movingNode.y)
renderPreview(renderer, 10)
expect(renderer.profiler.stats.scenePictureMode).toBe('volatile')
expect(renderer.profiler.stats.scenePictureMissReason).toBe('position-preview')
graph.updateNode(movingNodeId, { x: originalX + 20 })
renderPreview(renderer, 11)
expect(renderer.profiler.stats.scenePictureMode).toBe('record')
expect(renderer.profiler.stats.scenePictureMissReason).toBe('position-preview-version')
renderPreview(renderer, 11)
expect(renderer.profiler.stats.scenePictureMode).toBe('hit')
} finally {
surface.delete()
}
})
})