refactor: scope browser test instrumentation and global types (#667)

This commit is contained in:
Danila Poyarkov 2026-09-10 14:26:36 +03:00 committed by GitHub
parent 77739c42a4
commit a779edd08a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 153 additions and 243 deletions

View file

@ -139,7 +139,11 @@ Private tooling belongs under `tools/<domain>/{src,tests}`, with kebab-case doma
- Use `@/` for app cross-directory imports. Package aliases are `#vue/*`, `#cli/*`, `#dom-css/*`, `#mcp/*`, and `#core/*`; prefer clear relative imports nearby.
- No `any`, non-null assertions, or `Math.random()`; use precise types, guards, and `crypto.getRandomValues()`.
- Reuse named types and primitives from `@open-pencil/scene-graph`; do not respell `Color`, `Vector`, `SceneNode`, `Effect`, `Fill`, or `Stroke` shapes.
- Window API declarations belong in `src/global.d.ts` or `packages/core/src/global.d.ts`.
- Window API augmentations belong in the owning compilation boundary: app declarations in `src/global.d.ts`, package DOM gaps in the owning package's `global.d.ts`, and native-test declarations in `tests/helpers/tauri/native-global.d.ts`. Never put `declare global` in specs or implementation modules. Include canonical declarations through tsconfig instead of duplicating them.
- Keep app API contracts named and owned by their implementation domain; declaration files import those types. Derive vendor API types from top-level type imports rather than hand-copying signatures. Optional runtime globals remain optional and require a runtime guard.
- Native tests centralize invocation in a guarded test helper using vendor-derived types; do not import packages inside serialized WebView callbacks or repeat direct Tauri-global access in specs. Never expand production Window declarations just to accommodate test fixtures.
- Prefer test-runner-owned fixtures and request/route counters over browser globals. For in-page performance instrumentation, return a scoped `JSHandle` from `evaluateHandle()`; restore patched methods/listeners and dispose the handle in `finally`. Handles do not survive navigation. Assert transient DOM state with locators before the interaction ends when possible. Do not create a catch-all test Window interface or add ad-hoc counter properties to window.
- In Bun tests, prefer injected dependencies or scoped spies with explicit cleanup. `mock.restore()` restores spies but does not undo `mock.module()` overrides; do not assume module mocks are isolated by cleanup hooks. Read the installed runner's current lifecycle/mocking docs before introducing global or module-level instrumentation.
- Use `culori` for color conversion and existing dependencies before custom implementations.
- Prefer VueUse for common browser, event, focus, clipboard, storage, and timer behavior, but keep one-shot rAF or explicit service-owned timers when clearer.
- Components must not hold module-level mutable state. Share repeated logic/constants rather than copying it.

View file

@ -1,15 +1,3 @@
interface FontData {
family: string
fullName: string
postscriptName: string
style: string
blob(): Promise<Blob>
}
interface Window {
queryLocalFonts?(): Promise<FontData[]>
}
interface GestureEvent extends UIEvent {
scale: number
rotation: number

View file

@ -28,12 +28,6 @@ export interface OpenPencilWindowAPI {
test?: OpenPencilTestHooks
}
declare global {
interface Window {
openPencil?: OpenPencilWindowAPI
}
}
let activeStore: EditorStore | null = null
function windowAPI(): OpenPencilWindowAPI {

19
src/global.d.ts vendored
View file

@ -1,13 +1,8 @@
import type { OpenPencilWindowAPI } from './app/browser-bridge'
export {}
declare global {
interface GestureEvent extends UIEvent {
scale: number
rotation: number
clientX: number
clientY: number
}
interface FilePickerAcceptType {
description: string
accept: Record<string, string[]>
@ -20,17 +15,9 @@ declare global {
}
interface Window {
openPencil?: OpenPencilWindowAPI
showOpenFilePicker?(options?: FilePickerOptions): Promise<FileSystemFileHandle[]>
showSaveFilePicker?(options?: FilePickerOptions): Promise<FileSystemFileHandle>
queryLocalFonts?(): Promise<
{
family: string
fullName: string
style: string
postscriptName: string
blob(): Promise<Blob>
}[]
>
mockWindowOpen?(url: string): void
}
}

View file

@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
import { measureInteraction } from '#tests/helpers/performance/interaction'
test('dragging a nested card uses repaint-only position previews', async ({ page }) => {
await page.goto('/')
@ -46,48 +47,7 @@ test('dragging a nested card uses repaint-only position previews', async ({ page
})
await canvas.waitForRender()
await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const originalStoreUpdate = store.updateNode.bind(store)
const originalGraphUpdate = store.graph.updateNode.bind(store.graph)
let storeUpdateCount = 0
let graphUpdateCount = 0
let repaintCount = 0
store.updateNode = ((id, changes) => {
storeUpdateCount++
return originalStoreUpdate(id, changes)
}) as typeof store.updateNode
store.graph.updateNode = ((id, changes) => {
graphUpdateCount++
return originalGraphUpdate(id, changes)
}) as typeof store.graph.updateNode
store.onEditorEvent('repaint:requested', () => {
repaintCount++
})
Object.assign(window, {
__openPencilDragCounters: () => ({
storeUpdateCount,
graphUpdateCount,
repaintCount
})
})
})
await canvas.drag(110, 110, 210, 150, 20)
const counters = await page.evaluate(() => {
const getCounters = (
window as typeof window & {
__openPencilDragCounters?: () => {
storeUpdateCount: number
graphUpdateCount: number
repaintCount: number
}
}
).__openPencilDragCounters
return getCounters?.() ?? null
})
const counters = await measureInteraction(page, () => canvas.drag(110, 110, 210, 150, 20))
expect(counters?.storeUpdateCount).toBeLessThanOrEqual(1)
expect(counters?.graphUpdateCount).toBeLessThanOrEqual(25)

View file

@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
import { measureInteraction } from '#tests/helpers/performance/interaction'
test('resizing uses repaint-only previews until mouseup', async ({ page }) => {
await page.goto('/')
@ -39,44 +40,7 @@ test('resizing uses repaint-only previews until mouseup', async ({ page }) => {
})
await canvas.waitForRender()
await page.evaluate((selectedId) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const originalStoreUpdate = store.updateNode.bind(store)
const originalGraphUpdate = store.graph.updateNode.bind(store.graph)
let storeUpdateCount = 0
let graphUpdateCount = 0
let repaintCount = 0
store.updateNode = ((nodeId, changes) => {
if (nodeId === selectedId) storeUpdateCount++
return originalStoreUpdate(nodeId, changes)
}) as typeof store.updateNode
store.graph.updateNode = ((nodeId, changes) => {
if (nodeId === selectedId) graphUpdateCount++
return originalGraphUpdate(nodeId, changes)
}) as typeof store.graph.updateNode
store.onEditorEvent('repaint:requested', () => {
repaintCount++
})
Object.assign(window, {
__openPencilResizeCounters: () => ({ storeUpdateCount, graphUpdateCount, repaintCount })
})
}, id)
await canvas.drag(340, 240, 420, 290, 12)
const counters = await page.evaluate(() => {
const getCounters = (
window as typeof window & {
__openPencilResizeCounters?: () => {
storeUpdateCount: number
graphUpdateCount: number
repaintCount: number
}
}
).__openPencilResizeCounters
return getCounters?.() ?? null
})
const counters = await measureInteraction(page, () => canvas.drag(340, 240, 420, 290, 12), id)
const node = await page.evaluate((nodeId) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')

View file

@ -1,6 +1,7 @@
import { expect, test, type Page } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
import { mockGoogleFonts } from '#tests/helpers/fonts/google'
async function openTypographyForText(page: Page) {
await page.goto('/')
@ -25,51 +26,8 @@ async function searchFonts(page: Page, query: string) {
await page.getByRole('combobox', { name: 'Search fonts…' }).fill(query)
}
async function installGoogleFontsMock(page: Page, families = ['Inter', 'OpenPencil Google Font']) {
await page.addInitScript((googleFamilies) => {
const win = window as Window & {
__googleFontsFetchCount?: number
__googleFontPreviewFetchCount?: number
}
win.__googleFontsFetchCount = 0
win.__googleFontPreviewFetchCount = 0
const originalFetch = window.fetch.bind(window)
window.fetch = async (input, init) => {
let url: string
if (typeof input === 'string') url = input
else if (input instanceof URL) url = input.href
else url = input.url
if (url.startsWith('https://fonts.openpencil.test/')) {
win.__googleFontPreviewFetchCount = (win.__googleFontPreviewFetchCount ?? 0) + 1
return new Response(new ArrayBuffer(8), { status: 200 })
}
if (url.startsWith('https://fonts.google.com/metadata/fonts')) {
win.__googleFontsFetchCount = (win.__googleFontsFetchCount ?? 0) + 1
return new Response(
JSON.stringify({
familyMetadataList: googleFamilies.map((family) => ({
family,
axes: [],
fonts: { '400': {} }
}))
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
}
if (url.startsWith('https://fonts.googleapis.com/css2')) {
const family = new URL(url).searchParams.get('family')?.split(':')[0] ?? 'Inter'
return new Response(
`@font-face { font-family: '${family}'; font-style: normal; font-weight: 400; src: url(https://fonts.openpencil.test/${encodeURIComponent(family)}.ttf) format('truetype'); }`,
{ status: 200, headers: { 'content-type': 'text/css' } }
)
}
return originalFetch(input, init)
}
}, families)
}
test('font picker selects local fonts without browser web-font access', async ({ page }) => {
await installGoogleFontsMock(page)
const fonts = await mockGoogleFonts(page)
await page.addInitScript(() => {
Object.defineProperty(window, 'queryLocalFonts', {
configurable: true,
@ -109,17 +67,13 @@ test('font picker selects local fonts without browser web-font access', async ({
}, textId)
)
.toBe('OpenPencil Local Font')
expect(
await page.evaluate(
() => (window as Window & { __googleFontsFetchCount?: number }).__googleFontsFetchCount
)
).toBe(0)
expect(fonts.counts.metadata).toBe(0)
})
test('font picker keeps bundled fonts when local and web fonts are unavailable', async ({
page
}) => {
await installGoogleFontsMock(page)
const fonts = await mockGoogleFonts(page)
await page.addInitScript(() => {
Reflect.deleteProperty(window, 'queryLocalFonts')
})
@ -134,15 +88,11 @@ test('font picker keeps bundled fonts when local and web fonts are unavailable',
await expect(
page.getByTestId('font-picker-item').filter({ hasText: 'OpenPencil Google Font' })
).toHaveCount(0)
expect(
await page.evaluate(
() => (window as Window & { __googleFontsFetchCount?: number }).__googleFontsFetchCount
)
).toBe(0)
expect(fonts.counts.metadata).toBe(0)
})
test('font picker keeps bundled fonts when local font permission is rejected', async ({ page }) => {
await installGoogleFontsMock(page)
const fonts = await mockGoogleFonts(page)
await page.addInitScript(() => {
Object.defineProperty(window, 'queryLocalFonts', {
configurable: true,
@ -162,17 +112,13 @@ test('font picker keeps bundled fonts when local font permission is rejected', a
await expect(
page.getByTestId('font-picker-item').filter({ hasText: 'OpenPencil Google Font' })
).toHaveCount(0)
expect(
await page.evaluate(
() => (window as Window & { __googleFontsFetchCount?: number }).__googleFontsFetchCount
)
).toBe(0)
expect(fonts.counts.metadata).toBe(0)
})
test('font picker keeps bundled Inter available when local and Google fonts are unavailable', async ({
page
}) => {
await installGoogleFontsMock(page, [])
await mockGoogleFonts(page, [])
await page.addInitScript(() => {
Reflect.deleteProperty(window, 'queryLocalFonts')
})

View file

@ -10,19 +10,6 @@ async function dragLayerAndObserveIndicator(
targetId: string,
targetPosition: Vector
) {
await page.evaluate(() => {
const positions: string[] = []
new MutationObserver(() => {
for (const element of document.querySelectorAll<HTMLElement>(
'[data-slot="drop-indicator"]'
)) {
const position = element.dataset.dropPosition
if (position) positions.push(position)
}
}).observe(document.body, { subtree: true, childList: true, attributes: true })
Object.assign(window, { __layerDropPositions: positions })
})
const source = page.locator(`[data-node-id="${sourceId}"]`)
const target = page.locator(`[data-node-id="${targetId}"]`)
const sourceBox = await source.boundingBox()
@ -35,12 +22,11 @@ async function dragLayerAndObserveIndicator(
await page.mouse.move(targetBox.x + targetPosition.x, targetBox.y + targetPosition.y, {
steps: 20
})
await expect(target.locator('[data-slot="drop-indicator"]')).toBeVisible()
const indicator = target.locator('[data-slot="drop-indicator"]')
await expect(indicator).toBeVisible()
const position = await indicator.getAttribute('data-drop-position')
await page.mouse.up()
return page.evaluate(
() => (window as typeof window & { __layerDropPositions?: string[] }).__layerDropPositions ?? []
)
return position
}
test('layer reorder exposes a visible drop indicator before dropping', async ({ page }) => {
@ -67,7 +53,7 @@ test('layer reorder exposes a visible drop indicator before dropping', async ({
y: 2
})
expect(positions).toContain('above')
expect(positions).toBe('above')
canvas.assertNoErrors()
})
@ -98,6 +84,6 @@ test('layer child drop exposes a visible container highlight before dropping', a
y: 12
})
expect(positions).toContain('child')
expect(positions).toBe('child')
canvas.assertNoErrors()
})

View file

@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
import { seedLargeDocument } from '#tests/helpers/large-document'
import { measureHitTesting } from '#tests/helpers/performance/hit-testing'
type TimingSummary = {
hitTestMissMs: number
@ -31,36 +32,13 @@ test.describe.serial('large-document performance', () => {
const fixture = await seedLargeDocument(page, nodeCount)
await canvas.waitForRender()
await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const originalHitTest = store.graph.hitTest.bind(store.graph)
let calls = 0
let totalMs = 0
store.graph.hitTest = ((...args) => {
const startedAt = performance.now()
const result = originalHitTest(...args)
totalMs += performance.now() - startedAt
calls++
return result
}) as typeof store.graph.hitTest
Object.assign(window, {
__largeDocumentPointerProfile: () => ({ calls, totalMs })
})
})
const bounds = await canvas.canvas.boundingBox()
if (!bounds) throw new Error('Canvas bounds unavailable')
await page.mouse.move(bounds.x + 10, bounds.y + 10)
await page.mouse.move(bounds.x + bounds.width - 10, bounds.y + bounds.height - 10, {
steps: 40
})
const pointerProfile = await page.evaluate(() => {
const profile = (
window as typeof window & {
__largeDocumentPointerProfile?: () => { calls: number; totalMs: number }
}
).__largeDocumentPointerProfile
return profile?.() ?? { calls: 0, totalMs: 0 }
const pointerProfile = await measureHitTesting(page, async () => {
await page.mouse.move(bounds.x + 10, bounds.y + 10)
await page.mouse.move(bounds.x + bounds.width - 10, bounds.y + bounds.height - 10, {
steps: 40
})
})
const result = await page.evaluate((profile): Promise<TimingSummary> => {

View file

@ -0,0 +1,30 @@
import type { Page, Route } from '@playwright/test'
/** Route-owned counts live in the runner, not on window or in a patched fetch. */
export async function mockGoogleFonts(page: Page, families = ['Inter', 'OpenPencil Google Font']) {
const counts = { metadata: 0, previews: 0 }
const pattern =
/^https:\/\/(fonts\.openpencil\.test\/|fonts\.google\.com\/metadata\/fonts|fonts\.googleapis\.com\/css2)/
async function handle(route: Route) {
const url = new URL(route.request().url())
if (url.hostname === 'fonts.openpencil.test') {
counts.previews++
await route.fulfill({ status: 200, body: Buffer.alloc(8) })
} else if (url.hostname === 'fonts.google.com') {
counts.metadata++
await route.fulfill({
json: {
familyMetadataList: families.map((family) => ({ family, axes: [], fonts: { '400': {} } }))
}
})
} else {
const family = url.searchParams.get('family')?.split(':')[0] ?? 'Inter'
await route.fulfill({
contentType: 'text/css',
body: `@font-face { font-family: '${family}'; font-style: normal; font-weight: 400; src: url(https://fonts.openpencil.test/${encodeURIComponent(family)}.ttf) format('truetype'); }`
})
}
}
await page.route(pattern, handle)
return { counts, dispose: () => page.unroute(pattern, handle) }
}

View file

@ -0,0 +1,34 @@
import type { Page } from '@playwright/test'
export async function measureHitTesting(page: Page, run: () => Promise<void>) {
const probe = await page.evaluateHandle(() => {
const graph = window.openPencil?.getStore?.().graph
if (!graph) throw new Error('OpenPencil graph not initialized')
const original = graph.hitTest
let calls = 0
let totalMs = 0
graph.hitTest = (...args) => {
const start = performance.now()
const result = original.apply(graph, args)
totalMs += performance.now() - start
calls++
return result
}
return {
read: () => ({ calls, totalMs }),
dispose: () => {
graph.hitTest = original
}
}
})
try {
await run()
return await probe.evaluate((probe) => probe.read())
} finally {
try {
await probe.evaluate((probe) => probe.dispose())
} finally {
await probe.dispose()
}
}
}

View file

@ -0,0 +1,45 @@
import type { Page } from '@playwright/test'
/** Own the instrumentation and remote handle for exactly one interaction. */
export async function measureInteraction(
page: Page,
run: () => Promise<void>,
selectedId?: string
) {
const probe = await page.evaluateHandle((selectedId) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const update = store.updateNode
const graphUpdate = store.graph.updateNode
const counts = { storeUpdateCount: 0, graphUpdateCount: 0, repaintCount: 0 }
store.updateNode = (id, changes) => {
if (!selectedId || id === selectedId) counts.storeUpdateCount++
return update.call(store, id, changes)
}
store.graph.updateNode = (id, changes) => {
if (!selectedId || id === selectedId) counts.graphUpdateCount++
return graphUpdate.call(store.graph, id, changes)
}
const unsubscribe = store.onEditorEvent('repaint:requested', () => {
counts.repaintCount++
})
return {
read: () => ({ ...counts }),
dispose() {
store.updateNode = update
store.graph.updateNode = graphUpdate
unsubscribe()
}
}
}, selectedId)
try {
await run()
return await probe.evaluate((probe) => probe.read())
} finally {
try {
await probe.evaluate((probe) => probe.dispose())
} finally {
await probe.dispose()
}
}
}

View file

@ -57,12 +57,10 @@ export async function installTauriClipboardMock(page: Page): Promise<TauriClipbo
})
await page.addInitScript((bindingName) => {
const invoke = (
globalThis as typeof globalThis & {
[key: string]: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>
}
)[bindingName]
if (!invoke) throw new Error(`Playwright binding not found: ${bindingName}`)
const binding: unknown = Reflect.get(globalThis, bindingName)
if (typeof binding !== 'function')
throw new Error(`Playwright binding not found: ${bindingName}`)
const invoke = binding as (cmd: string, args?: Record<string, unknown>) => Promise<unknown>
let callbackId = 1
const callbacks = new Map<number, unknown>()

View file

@ -1,10 +1,8 @@
import { mockIPC } from '@tauri-apps/api/mocks'
const windowLike = globalThis as typeof globalThis & {
__TAURI_INTERNALS__?: unknown
__TAURI_EVENT_PLUGIN_INTERNALS__?: unknown
}
Object.assign(globalThis, { window: windowLike })
import { installTauriMockWindow } from './mocks'
installTauriMockWindow()
mockIPC((cmd, args) => {
if (cmd !== 'build_fig_file') throw new Error(`Unexpected command: ${cmd}`)

View file

@ -1,9 +1,5 @@
export function installTauriMockWindow() {
const windowLike = globalThis as typeof globalThis & {
__TAURI_INTERNALS__?: unknown
__TAURI_EVENT_PLUGIN_INTERNALS__?: unknown
}
Object.assign(globalThis, { window: windowLike })
Object.assign(globalThis, { window: globalThis })
}
export async function mockTauriIPC(
@ -26,5 +22,5 @@ export async function clearTauriMocks() {
clearMocks()
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
Reflect.deleteProperty(window, '__TAURI_EVENT_PLUGIN_INTERNALS__')
delete (globalThis as typeof globalThis & { window?: unknown }).window
Reflect.deleteProperty(globalThis, 'window')
}

View file

@ -89,7 +89,9 @@
},
"include": [
"src/**/*.ts",
"src/**/*.vue"
"src/**/*.vue",
"packages/core/src/global.d.ts",
"packages/vue/src/global.d.ts"
],
"references": [
{