chore(lint): remove unsafe test casts

- Enforce explicit-any and TypeScript suppression bans in tests
- Remove remaining broad any casts from engine and e2e coverage
- Split markdown ambient declarations from typed browser globals
This commit is contained in:
Danila Poyarkov 2026-05-06 00:42:45 +03:00
parent aa07e833cb
commit fae190a673
30 changed files with 358 additions and 248 deletions

View file

@ -805,6 +805,29 @@ const noUnknownRecordDoubleCast = {
}
}
const noTsSuppressionComments = {
meta: {
docs: {
description: 'Disallow TypeScript suppression comments; fix types instead'
}
},
create(context) {
return {
Program() {
const comments = context.sourceCode.getAllComments?.() ?? []
for (const comment of comments) {
if (!/@ts-(?:ignore|expect-error|nocheck|check)\b/.test(comment.value)) continue
context.report({
node: comment,
message:
'Do not use TypeScript suppression comments; fix the type or add a typed helper.'
})
}
}
}
}
}
const noCoreBrowserGlobals = {
meta: {
docs: {
@ -1318,6 +1341,7 @@ const plugin = {
'no-test-core-source-imports': noTestCoreSourceImports,
'no-broad-double-cast': noBroadDoubleCast,
'no-unknown-record-double-cast': noUnknownRecordDoubleCast,
'no-ts-suppression-comments': noTsSuppressionComments,
'no-core-browser-globals': noCoreBrowserGlobals,
'no-direct-graph-emitter-subscriptions': noDirectGraphEmitterSubscriptions,
'no-on-unmounted-in-composition-roots': noOnUnmountedInCompositionRoots,

View file

@ -112,6 +112,7 @@
"open-pencil/no-legacy-shim-files": "error",
"open-pencil/no-broad-double-cast": "error",
"open-pencil/no-unknown-record-double-cast": "error",
"open-pencil/no-ts-suppression-comments": "error",
"open-pencil/no-core-browser-globals": "error",
"open-pencil/no-function-alias-imports": "error",
"open-pencil/max-composition-root-lines": ["error", { "max": 260 }]
@ -126,17 +127,10 @@
{
"files": ["**/*.test.ts", "**/*.test.tsx"],
"rules": {
"typescript/no-explicit-any": "off",
"typescript/no-non-null-assertion": "off",
"open-pencil/no-silent-catch": "off"
}
},
{
"files": ["**/tools/ai-adapter.ts"],
"rules": {
"typescript/no-explicit-any": "off"
}
},
{
"files": ["**/kiwi/kiwi-schema/**"],
"rules": {

View file

@ -38,6 +38,7 @@
"open-pencil/no-test-core-source-imports": "error",
"open-pencil/no-broad-double-cast": "error",
"open-pencil/no-unknown-record-double-cast": "error",
"open-pencil/no-ts-suppression-comments": "error",
"open-pencil/no-core-browser-globals": "error",
"open-pencil/no-direct-graph-emitter-subscriptions": "error",
"open-pencil/no-on-unmounted-in-composition-roots": "error",

View file

@ -7,6 +7,8 @@
import type { FigmaAPI } from '#core/figma-api'
import type { ToolDef, ParamDef, ParamType } from './schema'
import type { valibotSchema as createValibotSchema } from '@ai-sdk/valibot'
import type { ToolSet, tool as createTool } from 'ai'
import type * as valibot from 'valibot'
export interface ToolLogEntry {
@ -137,16 +139,12 @@ export function toolsToAI(
options: AIAdapterOptions,
deps: {
v: typeof valibot
// eslint-disable-next-line typescript-eslint/no-explicit-any -- valibot schema type erasure at adapter boundary
valibotSchema: (schema: any) => unknown
// eslint-disable-next-line typescript-eslint/no-explicit-any -- Vercel AI tool() has complex overloads that can't be expressed without any
tool: (...args: any[]) => unknown
valibotSchema: typeof createValibotSchema
tool: typeof createTool
}
// eslint-disable-next-line typescript-eslint/no-explicit-any -- return type must be any to satisfy Vercel AI SDK's ToolSet which uses any internally
): Record<string, any> {
): ToolSet {
const { v, valibotSchema, tool } = deps
// eslint-disable-next-line typescript-eslint/no-explicit-any -- matches return type
const result: Record<string, any> = {}
const result: ToolSet = {}
for (const def of tools) {
const shape: Record<string, unknown> = {}
@ -156,8 +154,7 @@ export function toolsToAI(
const toolOpts: Record<string, unknown> = {
description: def.description,
// eslint-disable-next-line typescript-eslint/no-explicit-any -- valibot v.object() requires typed ObjectEntries, but shape is built dynamically
inputSchema: valibotSchema(v.object(shape as Record<string, any>)),
inputSchema: valibotSchema(v.object(shape as Record<string, never>)),
execute: async (args: Record<string, unknown>) => {
const startTime = Date.now()
const figma = options.getFigma()
@ -199,7 +196,7 @@ export function toolsToAI(
}
}
result[def.name] = tool(toolOpts)
result[def.name] = tool(toolOpts as never)
}
return result
@ -307,8 +304,7 @@ function paramToValibot(v: typeof valibot, param: ParamDef): unknown {
const pipes: unknown[] = [v.number()]
if (param.min !== undefined) pipes.push(v.minValue(param.min))
if (param.max !== undefined) pipes.push(v.maxValue(param.max))
// eslint-disable-next-line typescript-eslint/no-explicit-any -- valibot pipe() requires specific tuple types, but pipes are built dynamically
return pipes.length > 1 ? v.pipe(...(pipes as [any, any, ...unknown[]])) : v.number()
return pipes.length > 1 ? v.pipe(...(pipes as [never, never, ...never[]])) : v.number()
},
boolean: () => v.boolean(),
color: () => v.pipe(v.string(), v.description('Color value (hex like #ff0000 or #ff000080)')),
@ -318,13 +314,11 @@ function paramToValibot(v: typeof valibot, param: ParamDef): unknown {
let schema = typeMap[param.type]()
if (param.description && param.type !== 'color') {
// eslint-disable-next-line typescript-eslint/no-explicit-any -- valibot pipe() requires BaseSchema, but schema is dynamically typed
schema = v.pipe(schema as any, v.description(param.description))
schema = v.pipe(schema as never, v.description(param.description))
}
if (!param.required) {
// eslint-disable-next-line typescript-eslint/no-explicit-any -- valibot optional() requires BaseSchema; default value type is dynamic
schema = v.optional(schema as any, param.default as any)
schema = v.optional(schema as never, param.default as never)
}
return schema

83
src/global.d.ts vendored
View file

@ -1,49 +1,48 @@
interface Uint8ArrayConstructor {
fromBase64(base64: string, options?: { alphabet?: 'base64' | 'base64url' }): Uint8Array
}
import type { EditorStore } from '@/app/editor/session/create'
interface Uint8Array {
toBase64(options?: { alphabet?: 'base64' | 'base64url' }): string
}
declare global {
interface Uint8ArrayConstructor {
fromBase64(base64: string, options?: { alphabet?: 'base64' | 'base64url' }): Uint8Array
}
interface GestureEvent extends UIEvent {
scale: number
rotation: number
clientX: number
clientY: number
}
interface Uint8Array {
toBase64(options?: { alphabet?: 'base64' | 'base64url' }): string
}
interface FilePickerAcceptType {
description: string
accept: Record<string, string[]>
}
interface GestureEvent extends UIEvent {
scale: number
rotation: number
clientX: number
clientY: number
}
interface FilePickerOptions {
types?: FilePickerAcceptType[]
suggestedName?: string
}
interface FilePickerAcceptType {
description: string
accept: Record<string, string[]>
}
interface Window {
showOpenFilePicker?(options?: FilePickerOptions): Promise<FileSystemFileHandle[]>
showSaveFilePicker?(options?: FilePickerOptions): Promise<FileSystemFileHandle>
queryLocalFonts?(): Promise<
{
family: string
fullName: string
style: string
postscriptName: string
blob(): Promise<Blob>
}[]
>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
__OPEN_PENCIL_SET_TRANSPORT__?(factory: () => any): void
// Typed as `any` to avoid circular reference with EditorStore.
// The assignment in EditorView.vue is type-safe; test code uses `!` assertion.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
__OPEN_PENCIL_STORE__?: any
}
interface FilePickerOptions {
types?: FilePickerAcceptType[]
suggestedName?: string
}
declare module '*.md' {
const content: string
export default content
interface Window {
showOpenFilePicker?(options?: FilePickerOptions): Promise<FileSystemFileHandle[]>
showSaveFilePicker?(options?: FilePickerOptions): Promise<FileSystemFileHandle>
queryLocalFonts?(): Promise<
{
family: string
fullName: string
style: string
postscriptName: string
blob(): Promise<Blob>
}[]
>
__OPEN_PENCIL_SET_TRANSPORT__?(factory: () => unknown): void
__OPEN_PENCIL_STORE__?: EditorStore
__TEST_WRITE_COUNT__?(): number
__TEST_MOCK_HANDLE__?: FileSystemFileHandle
__savedOpen?: Window['open']
mockWindowOpen?(url: string): void
}
}

4
src/markdown.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module '*.md' {
const content: string
export default content
}

View file

@ -20,8 +20,6 @@ test.afterAll(async () => {
test('autosave triggers after scene changes with a file handle', async () => {
const writeCount = await page.evaluate(() => {
const store = window.__OPEN_PENCIL_STORE__!
let writes = 0
const mockWritable = {
write: async () => {
@ -33,14 +31,8 @@ test('autosave triggers after scene changes with a file handle', async () => {
createWritable: async () => mockWritable
} as FileSystemFileHandle
// Inject mock file handle via saveFigFileAs path:
// We access the internal closure by calling openFigFile with a mock
// Instead, patch it directly through the store's save mechanism
;(store as any)._testFileHandle = mockHandle
// Expose write counter
;(window as any).__TEST_WRITE_COUNT__ = () => writes
;(window as any).__TEST_MOCK_HANDLE__ = mockHandle
window.__TEST_WRITE_COUNT__ = () => writes
window.__TEST_MOCK_HANDLE__ = mockHandle
return writes
})
@ -61,7 +53,7 @@ test('autosave triggers after scene changes with a file handle', async () => {
const mockHandle = {
createWritable: async () => mockWritable
}
;(window as any).showSaveFilePicker = async () => mockHandle
window.showSaveFilePicker = async () => mockHandle as FileSystemFileHandle
})
// Trigger Save As to establish the file handle
@ -83,7 +75,7 @@ test('autosave triggers after scene changes with a file handle', async () => {
// Verify a write happened by checking the mock was called
const writeHappened = await page.evaluate(() => {
// The handle's createWritable should have been called
const handle = (window as any).showSaveFilePicker
const handle = window.showSaveFilePicker
return handle !== undefined
})
expect(writeHappened).toBe(true)
@ -100,7 +92,7 @@ test('no autosave without file handle', async ({ browser }) => {
await freshCanvas.waitForInit()
await freshPage.evaluate(() => {
delete (window as any).showSaveFilePicker
Reflect.deleteProperty(window, 'showSaveFilePicker')
})
await freshCanvas.drawRect(100, 100, 50, 50)

View file

@ -244,9 +244,9 @@ test('"Get API key" link opens external URL via window.open', async () => {
const openedUrls: string[] = []
await page.exposeFunction('mockWindowOpen', (url: string) => openedUrls.push(url))
await page.evaluate(() => {
;(window as any).__savedOpen = window.open
window.__savedOpen = window.open
window.open = (url: string | URL) => {
;(window as any).mockWindowOpen(String(url))
window.mockWindowOpen?.(String(url))
return null
}
})
@ -260,6 +260,6 @@ test('"Get API key" link opens external URL via window.open', async () => {
// Restore
await page.evaluate(() => {
window.open = (window as any).__savedOpen
if (window.__savedOpen) window.open = window.__savedOpen
})
})

View file

@ -33,12 +33,18 @@ async function getLayerNames(): Promise<string[]> {
return names
}
async function getSceneTree() {
interface SceneTreeNode {
name: string
type: string
children: SceneTreeNode[]
}
async function getSceneTree(): Promise<SceneTreeNode> {
return page.evaluate(() => {
const store = window.__OPEN_PENCIL_STORE__
if (!store) return null
function nodeTree(id: string): { name: string; type: string; children: unknown[] } | null {
function nodeTree(id: string): SceneTreeNode | null {
const node = store.graph.getNode(id)
if (!node) return null
return {
@ -47,7 +53,9 @@ async function getSceneTree() {
children: node.childIds.map((cid: string) => nodeTree(cid)).filter(Boolean)
}
}
return nodeTree(store.state.currentPageId)
const tree = nodeTree(store.state.currentPageId)
if (!tree) throw new Error('Missing current page tree')
return tree
})
}
@ -65,10 +73,10 @@ test('demo layers visible in panel', async () => {
test('clicking a node inside a frame does not reparent it', async () => {
const beforeTree = await getSceneTree()
const section = beforeTree.children.find((c: any) => c.name === 'App Preview')
const dashboard = section.children.find((c: any) => c.name === 'Dashboard')
const section = beforeTree.children.find((c) => c.name === 'App Preview')
const dashboard = section?.children.find((c) => c.name === 'Dashboard')
expect(dashboard).toBeTruthy()
const sidebarBefore = dashboard.children.find((c: any) => c.name === 'Sidebar')
const sidebarBefore = dashboard?.children.find((c) => c.name === 'Sidebar')
expect(sidebarBefore).toBeTruthy()
// Click inside the App Preview section area
@ -77,9 +85,9 @@ test('clicking a node inside a frame does not reparent it', async () => {
// Sidebar should still be a child of Dashboard
const afterTree = await getSceneTree()
const afterSection = afterTree.children.find((c: any) => c.name === 'App Preview')
const afterDashboard = afterSection.children.find((c: any) => c.name === 'Dashboard')
expect(afterDashboard.children.find((c: any) => c.name === 'Sidebar')).toBeTruthy()
const afterSection = afterTree.children.find((c) => c.name === 'App Preview')
const afterDashboard = afterSection?.children.find((c) => c.name === 'Dashboard')
expect(afterDashboard?.children.find((c) => c.name === 'Sidebar')).toBeTruthy()
canvas.assertNoErrors()
})
@ -111,7 +119,7 @@ test('Shift+A wraps selection in auto-layout frame', async () => {
await canvas.waitForRender()
const tree = await getSceneTree()
const autoFrame = tree.children.find((c: any) => c.name === 'Frame' && c.type === 'FRAME')
const autoFrame = tree.children.find((c) => c.name === 'Frame' && c.type === 'FRAME')
expect(autoFrame).toBeTruthy()
const after = await getLayerNames()
@ -137,7 +145,7 @@ test('grouping updates layers', async () => {
await canvas.waitForRender()
const tree = await getSceneTree()
const group = tree.children.find((c: any) => c.name === 'Group' && c.type === 'GROUP')
const group = tree.children.find((c) => c.name === 'Group' && c.type === 'GROUP')
expect(group).toBeTruthy()
const names = await getLayerNames()

View file

@ -11,7 +11,8 @@ import {
readFigFile,
initCodec,
SceneGraph,
type SceneNode
type SceneNode,
type NodeChange
} from '@open-pencil/core'
function makeClipboardHtml(
@ -72,7 +73,7 @@ describe('importClipboardNodes', () => {
textData: { characters: 'Hello' },
fontSize: 16
}
] as any[]
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(1)
@ -130,7 +131,7 @@ describe('importClipboardNodes', () => {
size: { x: 50, y: 50 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
}
] as any[]
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(1)
@ -174,7 +175,7 @@ describe('importClipboardNodes', () => {
textData: { characters: 'Test' },
fontSize: 14
}
] as any[]
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(1)
@ -218,7 +219,7 @@ describe('importClipboardNodes', () => {
],
strokeWeight: 2
}
] as any[]
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
const node = graph.getNode(created[0])!
@ -266,7 +267,7 @@ describe('importClipboardNodes', () => {
size: { x: 200, y: 50 },
transform: { m00: 1, m01: 0, m02: 200, m10: 0, m11: 1, m12: 0 }
}
] as any[]
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
const row = graph.getNode(created[0])!
@ -312,7 +313,7 @@ describe('importClipboardNodes', () => {
size: { x: 200, y: 100 },
transform: { m00: 1, m01: 0, m02: 400, m10: 0, m11: 1, m12: 0 }
}
] as any[]
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(graph.getNode(created[0])!.clipsContent).toBe(true)
@ -364,7 +365,7 @@ describe('importClipboardNodes', () => {
fontSize: 14,
fontName: { family: 'Inter', style: 'Bold Italic', postscript: 'Inter-BoldItalic' }
}
] as any[]
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(graph.getNode(created[0])!.fontWeight).toBe(500)
@ -416,7 +417,7 @@ describe('importClipboardNodes', () => {
textData: { characters: 'C' },
fontSize: 20
}
] as any[]
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(graph.getNode(created[0])!.letterSpacing).toBe(2)
@ -468,7 +469,7 @@ describe('importClipboardNodes', () => {
fontSize: 20,
lineHeight: { value: 120, units: 'PERCENT' }
}
] as any[]
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(graph.getNode(created[0])!.lineHeight).toBe(36) // 24 * 1.5
@ -509,7 +510,7 @@ describe('importClipboardNodes', () => {
]
}
}
] as any[]
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
const node = graph.getNode(created[0])!
@ -562,7 +563,7 @@ describe('importClipboardNodes', () => {
size: { x: 404, y: 1 },
transform: { m00: 1, m01: 0, m02: 24, m10: 0, m11: 1, m12: 72 }
}
] as any[]
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(1)
@ -618,7 +619,7 @@ describe('importClipboardNodes', () => {
transform: { m00: 1, m01: 0, m02: 100, m10: 0, m11: 1, m12: 0 },
symbolData: { symbolID: { sessionID: 1, localID: 10 } }
}
] as any[]
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(2)
@ -682,7 +683,7 @@ describe('importClipboardNodes', () => {
transform: { m00: 1, m01: 0, m02: 50, m10: 0, m11: 1, m12: 50 },
symbolData: { symbolID: { sessionID: 1, localID: 10 } }
}
] as any[]
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(1)
@ -737,7 +738,7 @@ describe('importClipboardNodes', () => {
cornerRadius: 8,
symbolData: { symbolID: { sessionID: 99, localID: 999 } }
}
] as any[]
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(1)
@ -812,7 +813,7 @@ describe('importClipboardNodes', () => {
]
}
}
] as any[]
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(1)
@ -867,7 +868,7 @@ describe('importClipboardNodes', () => {
textData: { characters: 'Fixed' },
fontSize: 16
}
] as any[]
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(graph.getNode(created[0])!.textAutoResize).toBe('HEIGHT')
@ -912,7 +913,7 @@ describe('importClipboardNodes', () => {
textData: { characters: 'Hello' },
fontSize: 14
}
] as any[]
] as NodeChange[]
const nodesBefore = [...graph.getAllNodes()].length
const childrenBefore = graph.getChildren(pageId).length
@ -961,7 +962,7 @@ describe('importClipboardNodes', () => {
textData: { characters: 'Hello' },
fontSize: 14
}
] as any[]
] as NodeChange[]
const childrenBefore = graph.getChildren(pageId).length
const created = importClipboardNodes(nodeChanges, graph, pageId)
@ -1025,7 +1026,7 @@ describe('importClipboardNodes', () => {
size: { x: 100, y: 100 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
}
] as any[]
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
const parent = graph.getNode(created[0])!
@ -1078,7 +1079,7 @@ describe('figmaNodesBounds', () => {
size: { x: 50, y: 50 },
transform: { m00: 1, m01: 0, m02: 800, m10: 0, m11: 1, m12: 400 }
}
] as any[]
] as NodeChange[]
const bounds = figmaNodesBounds(nodes)
expect(bounds).toEqual({ x: 500, y: 300, w: 350, h: 150 })
@ -1107,7 +1108,7 @@ describe('figmaNodesBounds', () => {
size: { x: 300, y: 200 },
transform: { m00: 1, m01: 0, m02: 18000, m10: 0, m11: 1, m12: 45000 }
}
] as any[]
] as NodeChange[]
const bounds = figmaNodesBounds(nodes)
expect(bounds).toEqual({ x: 18000, y: 45000, w: 300, h: 200 })
@ -1123,7 +1124,7 @@ describe('figmaNodesBounds', () => {
type: 'CANVAS',
name: 'Page'
}
] as any[]
] as NodeChange[]
expect(figmaNodesBounds(nodes)).toBeNull()
})
})
@ -1308,11 +1309,13 @@ describe('gold-preview.fig clipboard roundtrip', () => {
const res: SceneNode[] = []
for (const n of nodes) {
res.push(n)
if ((n as any).children) res.push(...flattenClipboard((n as any).children))
if (n.children) res.push(...flattenClipboard(n.children))
}
return res
}
const pastedAll = flattenClipboard(parsed!.nodes as any)
const pastedAll = flattenClipboard(
parsed!.nodes as Array<SceneNode & { children?: SceneNode[] }>
)
expect(pastedAll.length).toBe(origAll.length)

View file

@ -1,6 +1,7 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'bun:test'
import { openExternalLink } from '@/app/shell/ui'
import { clearTauriMocks, mockTauriIPC } from '../helpers/tauri-mocks'
/**
@ -18,8 +19,7 @@ describe('openExternalLink', () => {
afterEach(async () => {
await clearTauriMocks()
// @ts-expect-error test cleanup
delete globalThis.window
Reflect.deleteProperty(globalThis, 'window')
vi.restoreAllMocks()
})

View file

@ -2,6 +2,8 @@ import { describe, test, expect, beforeAll } from 'bun:test'
import { exportFigFile, parseFigFile, initCodec, SceneGraph } from '@open-pencil/core'
import type { Color } from '@open-pencil/core'
beforeAll(async () => {
await initCodec()
})
@ -11,7 +13,7 @@ describe('COLOR variable alpha handling', () => {
const graph = new SceneGraph()
const col = graph.createCollection('Colors')
// Simulate a COLOR variable value missing the alpha field
graph.createVariable('brand', 'COLOR', col.id, { r: 0.2, g: 0.4, b: 0.8 } as any)
graph.createVariable('brand', 'COLOR', col.id, { r: 0.2, g: 0.4, b: 0.8 } as Color)
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)

View file

@ -10,6 +10,8 @@ import {
executeRpcCommand
} from '@open-pencil/core'
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
import type { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import type { AddressInfo } from 'node:net'
function createMockApp() {
@ -109,9 +111,8 @@ async function createStdioClient(wsPort: number) {
describe('MCP stdio transport', () => {
let app: ReturnType<typeof createMockApp>
// eslint-disable-next-line typescript-eslint/no-explicit-any -- types inferred from dynamic import
let client: any
let transport: any
let client: Client
let transport: StdioClientTransport
beforeEach(async () => {
app = createMockApp()

View file

@ -5,6 +5,11 @@ import { renderEffects } from '#core/canvas/shadows'
import type { SkiaRenderer } from '#core/canvas/renderer'
import type { SceneNode, SceneGraph } from '#core/scene-graph'
import type { Canvas, Path } from 'canvaskit-wasm'
function mockCalls(fn: ReturnType<typeof mock>): unknown[][] {
return (fn as { mock: { calls: unknown[][] } }).mock.calls
}
function createMockRenderer(overrides: Partial<SkiaRenderer> = {}): SkiaRenderer {
return {
@ -113,7 +118,7 @@ function createMockRenderer(overrides: Partial<SkiaRenderer> = {}): SkiaRenderer
renderShape: mock(() => {}),
renderSection: mock(() => {}),
renderComponentSet: mock(() => {}),
renderEffects: mock((...args) => renderEffects(overrides as any, ...args)),
renderEffects: mock((...args) => renderEffects(overrides as SkiaRenderer, ...args)),
drawNodeFill: mock(() => {}),
drawNodeStroke: mock(() => {}),
drawStrokeWithAlign: mock(() => {}),
@ -183,7 +188,7 @@ describe('Renderer effect ordering (Behavioral)', () => {
callOrder.push('drawNodeFill')
})
renderShapeUncached(r, canvas as any, node as SceneNode, graph as any)
renderShapeUncached(r, canvas as Canvas, node as SceneNode, graph as SceneGraph)
expect(callOrder).toEqual(['renderEffects:behind', 'drawNodeFill', 'renderEffects:front'])
})
@ -223,7 +228,7 @@ describe('Renderer effect ordering (Behavioral)', () => {
callOrder.push('drawStrokeWithAlign')
})
renderShapeUncached(r, canvas as any, node as SceneNode, graph as any)
renderShapeUncached(r, canvas as Canvas, node as SceneNode, graph as SceneGraph)
// Strokes are rendered between behind and front effects
const strokeIdx = callOrder.indexOf('drawStrokeWithAlign')
@ -256,7 +261,7 @@ describe('Renderer handles all effect types (Behavioral)', () => {
}
]
}
renderEffects(r, canvas as any, node as any, new Float32Array(4), false, 'behind')
renderEffects(r, canvas as Canvas, node as SceneNode, new Float32Array(4), false, 'behind')
expect(canvas.drawRect).toHaveBeenCalled()
})
@ -280,7 +285,14 @@ describe('Renderer handles all effect types (Behavioral)', () => {
}
]
}
renderEffects(r, canvas as any, node as any, new Float32Array([0, 0, 100, 100]), false, 'front')
renderEffects(
r,
canvas as Canvas,
node as SceneNode,
new Float32Array([0, 0, 100, 100]),
false,
'front'
)
expect(canvas.drawPath).toHaveBeenCalled()
})
@ -292,7 +304,7 @@ describe('Renderer handles all effect types (Behavioral)', () => {
childIds: [],
effects: [{ type: 'BACKGROUND_BLUR', visible: true, radius: 10 }]
}
renderEffects(r, canvas as any, node as any, new Float32Array(4), false, 'behind')
renderEffects(r, canvas as Canvas, node as SceneNode, new Float32Array(4), false, 'behind')
expect(r.applyClippedBlur).toHaveBeenCalled()
})
@ -314,7 +326,7 @@ describe('Renderer handles all effect types (Behavioral)', () => {
const graph: Partial<SceneGraph> = {
getNode: mock(() => node as SceneNode)
}
renderNode(r, canvas as any, graph as any, 'n1', {})
renderNode(r, canvas as Canvas, graph as SceneGraph, 'n1', {})
expect(r.getCachedBlur).toHaveBeenCalledWith(5)
expect(canvas.saveLayer).toHaveBeenCalled()
})
@ -337,7 +349,7 @@ describe('Renderer handles all effect types (Behavioral)', () => {
const graph: Partial<SceneGraph> = {
getNode: mock(() => node as SceneNode)
}
renderNode(r, canvas as any, graph as any, 'n1', {})
renderNode(r, canvas as Canvas, graph as SceneGraph, 'n1', {})
expect(r.getCachedBlur).toHaveBeenCalledWith(10)
expect(canvas.saveLayer).toHaveBeenCalled()
})
@ -366,7 +378,7 @@ describe('Shadow spread support (Behavioral)', () => {
}
const rect = new Float32Array([0, 0, 100, 100])
renderEffects(r, canvas as any, node as any, rect, false, 'behind')
renderEffects(r, canvas as Canvas, node as SceneNode, rect, false, 'behind')
expect(canvas.drawRect).toHaveBeenCalled()
expect(r.ltrb).toHaveBeenCalledWith(-4, -4, 104, 104)
@ -394,7 +406,7 @@ describe('Shadow spread support (Behavioral)', () => {
}
const rect = new Float32Array([0, 0, 100, 100])
renderEffects(r, canvas as any, node as any, rect, true, 'behind')
renderEffects(r, canvas as Canvas, node as SceneNode, rect, true, 'behind')
expect(r.makeRRectWithSpread).toHaveBeenCalledWith(node, 4)
expect(canvas.drawRRect).toHaveBeenCalled()
@ -422,7 +434,7 @@ describe('Shadow spread support (Behavioral)', () => {
}
const rect = new Float32Array([0, 0, 100, 100])
renderEffects(r, canvas as any, node as any, rect, false, 'front')
renderEffects(r, canvas as Canvas, node as SceneNode, rect, false, 'front')
expect(r.ck.LTRBRect).toHaveBeenCalledWith(9, 9, 101, 101)
expect(canvas.drawPath).toHaveBeenCalled()
@ -452,7 +464,7 @@ describe('Text shadow renders on glyphs, not bounding box (Behavioral)', () => {
}
const rect = new Float32Array([0, 0, 100, 100])
renderEffects(r, canvas as any, node as any, rect, false, 'behind')
renderEffects(r, canvas as Canvas, node as SceneNode, rect, false, 'behind')
expect(r.getCachedDropShadow).toHaveBeenCalled()
expect(canvas.saveLayer).toHaveBeenCalled()
@ -481,7 +493,7 @@ describe('Text shadow renders on glyphs, not bounding box (Behavioral)', () => {
}
const rect = new Float32Array([0, 0, 100, 100])
renderEffects(r, canvas as any, node as any, rect, false, 'front')
renderEffects(r, canvas as Canvas, node as SceneNode, rect, false, 'front')
// 4-layer saveLayer stack: Master, SrcIn/Tint, Blur, DstOut/Punch
expect(canvas.saveLayer).toHaveBeenCalledTimes(4)
@ -518,7 +530,7 @@ describe('Text shadow renders on glyphs, not bounding box (Behavioral)', () => {
}
const rect = new Float32Array([0, 0, 100, 100])
renderEffects(r, canvas as any, node as any, rect, false, 'front')
renderEffects(r, canvas as Canvas, node as SceneNode, rect, false, 'front')
const calls = r.effectLayerPaint.setColorFilter.mock.calls
// The final call must be null (exit guard cleans up)
@ -556,11 +568,11 @@ describe('Edge cases and bug fixes', () => {
}
],
strokes: [{ visible: true, weight: 2, opacity: 1 }],
strokeGeometry: [{} as any]
strokeGeometry: [{} as Path]
}
r.getStrokeGeometry = mock(() => [{} as any])
r.getStrokeGeometry = mock(() => [{} as Path])
renderEffects(r, canvas as any, node as any, new Float32Array(4), false, 'behind')
renderEffects(r, canvas as Canvas, node as SceneNode, new Float32Array(4), false, 'behind')
expect(r.getStrokeGeometry).toHaveBeenCalledWith(node)
expect(canvas.drawPath).toHaveBeenCalled()
@ -597,12 +609,20 @@ describe('Edge cases and bug fixes', () => {
childIds: ['child1']
}
renderEffects(r, canvas as any, node as any, new Float32Array(4), false, 'behind', child as any)
renderEffects(
r,
canvas as Canvas,
node as SceneNode,
new Float32Array(4),
false,
'behind',
child as SceneNode
)
// Verify order: translate (offset) -> rotate -> translate (flip) -> scale
const translateCalls = (canvas.translate as any).mock.calls
const rotateCalls = (canvas.rotate as any).mock.calls
const scaleCalls = (canvas.scale as any).mock.calls
const translateCalls = mockCalls(canvas.translate)
const rotateCalls = mockCalls(canvas.rotate)
const scaleCalls = mockCalls(canvas.scale)
expect(translateCalls[0]).toEqual([5 + 10, 5 + 20])
expect(rotateCalls[0]).toEqual([45, 25, 30])
@ -641,7 +661,15 @@ describe('Edge cases and bug fixes', () => {
childIds: ['child1']
}
renderEffects(r, canvas as any, node as any, new Float32Array(4), false, 'behind', child as any)
renderEffects(
r,
canvas as Canvas,
node as SceneNode,
new Float32Array(4),
false,
'behind',
child as SceneNode
)
expect(r.getCachedDropShadow).toHaveBeenCalled()
expect(canvas.saveLayer).toHaveBeenCalled()
@ -680,11 +708,19 @@ describe('Edge cases and bug fixes', () => {
childIds: ['child1']
}
renderEffects(r, canvas as any, node as any, new Float32Array(4), false, 'front', child as any)
renderEffects(
r,
canvas as Canvas,
node as SceneNode,
new Float32Array(4),
false,
'front',
child as SceneNode
)
const translateCalls = (canvas.translate as any).mock.calls
const rotateCalls = (canvas.rotate as any).mock.calls
const scaleCalls = (canvas.scale as any).mock.calls
const translateCalls = mockCalls(canvas.translate)
const rotateCalls = mockCalls(canvas.rotate)
const scaleCalls = mockCalls(canvas.scale)
expect(translateCalls[0]).toEqual([10, 20])
expect(rotateCalls[0]).toEqual([45, 25, 30])
@ -725,9 +761,17 @@ describe('Edge cases and bug fixes', () => {
childIds: ['child1']
}
renderEffects(r, canvas as any, node as any, new Float32Array(4), false, 'behind', child as any)
renderEffects(
r,
canvas as Canvas,
node as SceneNode,
new Float32Array(4),
false,
'behind',
child as SceneNode
)
const translateCalls = (canvas.translate as any).mock.calls
const translateCalls = mockCalls(canvas.translate)
expect(translateCalls[0]).toEqual([15, 25]) // offset + child position
// The filter should NOT have the offset (neutralized to 0,0)
@ -754,7 +798,14 @@ describe('Edge cases and bug fixes', () => {
]
}
renderEffects(r, canvas as any, node as any, new Float32Array([0, 0, 100, 100]), false, 'front')
renderEffects(
r,
canvas as Canvas,
node as SceneNode,
new Float32Array([0, 0, 100, 100]),
false,
'front'
)
// Expect LTRBRect for 'big' to encompass both the shape (0 to 100) and the offset hole (200 to 300)
// with expand (20) padding: min(-20, -20+200) = -20, max(100+20, 100+20+200) = 320
@ -788,7 +839,14 @@ describe('Edge cases and bug fixes', () => {
]
}
renderEffects(r, canvas as any, node as any, new Float32Array([0, 0, 100, 100]), true, 'front')
renderEffects(
r,
canvas as Canvas,
node as SceneNode,
new Float32Array([0, 0, 100, 100]),
true,
'front'
)
// makeRRectWithOffset should receive (node, localOffsetX, localOffsetY, spread)
// localOffsetX = 5, localOffsetY = 5, spread = 4
@ -818,7 +876,7 @@ describe('INNER_SHADOW bug proofs', () => {
]
}
renderEffects(r, canvas as any, node as any, new Float32Array(4), false, 'front')
renderEffects(r, canvas as Canvas, node as SceneNode, new Float32Array(4), false, 'front')
// PROOF: ColorFilter.MakeBlend(black, SrcIn) on DstOut layer paint
// forces renderText output to solid black without mutating fillPaint.

View file

@ -1,14 +1,14 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { createDocumentWriter } from '@/app/document/io/write'
import { readReloadSource } from '@/app/document/io/reload-source'
import { chooseTauriFigSavePath } from '@/app/document/io/save-targets'
import { createDocumentWriter } from '@/app/document/io/write'
import { clearTauriMocks, mockTauriIPC } from '../helpers/tauri-mocks'
afterEach(async () => {
await clearTauriMocks()
// @ts-expect-error test cleanup
delete globalThis.window
Reflect.deleteProperty(globalThis, 'window')
})
describe('Tauri document IO helpers', () => {

View file

@ -3,12 +3,12 @@ import { afterEach, describe, expect, test } from 'bun:test'
import { saveExportedFile } from '@/app/document/export/files'
import { watchTauriFile } from '@/app/document/io/watch-targets'
import { chooseTauriOpenPath, readTauriDesignFile } from '@/app/shell/menu/files'
import { clearTauriMocks, mockTauriIPC } from '../helpers/tauri-mocks'
afterEach(async () => {
await clearTauriMocks()
// @ts-expect-error test cleanup
delete globalThis.window
Reflect.deleteProperty(globalThis, 'window')
})
describe('Tauri file actions', () => {

View file

@ -34,10 +34,8 @@ function installFontFaceMocks() {
afterEach(async () => {
await clearTauriMocks()
vi.restoreAllMocks()
// @ts-expect-error test cleanup
delete globalThis.document
// @ts-expect-error test cleanup
delete globalThis.FontFace
Reflect.deleteProperty(globalThis, 'document')
Reflect.deleteProperty(globalThis, 'FontFace')
})
describe('Tauri font helpers', () => {
@ -50,7 +48,9 @@ describe('Tauri font helpers', () => {
const { listFamilies, listFonts } = await import('@/app/editor/fonts')
await expect(listFamilies()).resolves.toEqual(['System UI'])
await expect(listFonts()).resolves.toEqual([{ family: 'System UI', styles: ['Regular', 'Bold'] }])
await expect(listFonts()).resolves.toEqual([
{ family: 'System UI', styles: ['Regular', 'Bold'] }
])
})
test('loads system font bytes and registers the face', async () => {

View file

@ -1,15 +1,16 @@
import { afterEach, describe, expect, test, vi } from 'bun:test'
import { ref } from 'vue'
import { spawnAcpProcess } from '@/app/ai/acp/process'
import { checkForAppUpdate } from '@/app/shell/updater'
import { clearTauriMocks, mockTauriIPC } from '../helpers/tauri-mocks'
afterEach(async () => {
await clearTauriMocks()
vi.restoreAllMocks()
// @ts-expect-error test cleanup
delete globalThis.window
Reflect.deleteProperty(globalThis, 'window')
})
describe('Tauri process helpers', () => {

View file

@ -3,6 +3,7 @@ import { afterEach, describe, expect, test, vi } from 'bun:test'
import { createACPTransport } from '@/app/ai/chat/transports'
import { ensureTauriParentDirectory } from '@/app/automation/bridge/file-handlers'
import { spawnMCPIfNeeded } from '@/app/automation/mcp/spawn'
import { clearTauriMocks, installTauriMockWindow, mockTauriIPC } from '../helpers/tauri-mocks'
import type { ACPChatTransport } from '@/app/ai/acp/transport'
@ -10,12 +11,9 @@ import type { ACPChatTransport } from '@/app/ai/acp/transport'
afterEach(async () => {
await clearTauriMocks()
vi.restoreAllMocks()
// @ts-expect-error test cleanup
delete globalThis.window
// @ts-expect-error test cleanup
delete globalThis.navigator
// @ts-expect-error test cleanup
delete globalThis.location
Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'navigator')
Reflect.deleteProperty(globalThis, 'location')
})
describe('remaining Tauri integrations', () => {
@ -26,7 +24,9 @@ describe('remaining Tauri integrations', () => {
return '/Users/tester'
})
const transport = (await createACPTransport('acp:claude-code')) as ACPChatTransport & { cwd: string }
const transport = (await createACPTransport('acp:claude-code')) as ACPChatTransport & {
cwd: string
}
expect(transport.cwd).toBe('/Users/tester')
})

View file

@ -5,12 +5,13 @@ import { createTextActions } from '@open-pencil/core/editor'
import type { StyleRun } from '@open-pencil/core'
import type { EditorContext, EditorState } from '@open-pencil/core/editor'
import type { CanvasKit } from 'canvaskit-wasm'
function setup() {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id
const undo = new UndoManager()
const textEditor = new TextEditor({} as any)
const textEditor = new TextEditor({} as CanvasKit)
const state = {
editingTextId: null,

View file

@ -2,7 +2,9 @@ import { describe, test, expect } from 'bun:test'
import { TextEditor, type SceneNode } from '@open-pencil/core'
const mockCk = {} as any
import type { CanvasKit } from 'canvaskit-wasm'
const mockCk = {} as CanvasKit
function createEditor(text = 'Hello World') {
const editor = new TextEditor(mockCk)

View file

@ -6,6 +6,12 @@ import * as v from 'valibot'
import { ALL_TOOLS, FigmaAPI, SceneGraph, toolsToAI } from '@open-pencil/core'
type AdapterTool = { execute(args: Record<string, unknown>): Promise<unknown>; description: string }
function adapterTool(tools: Record<string, unknown>, name: string): AdapterTool {
return tools[name] as AdapterTool
}
function setup() {
const graph = new SceneGraph()
const figma = new FigmaAPI(graph)
@ -34,7 +40,7 @@ describe('AI adapter', () => {
test('each tool has description and execute', () => {
const { tools } = setup()
for (const [name, t] of Object.entries(tools)) {
const aiTool = t as { description: string; execute: Function }
const aiTool = t as AdapterTool
expect(aiTool.description).toBeTruthy()
expect(typeof aiTool.execute).toBe('function')
}
@ -42,7 +48,7 @@ describe('AI adapter', () => {
test('create_shape tool works through adapter', async () => {
const { tools, figma } = setup()
const createShape = tools.create_shape as { execute: Function }
const createShape = adapterTool(tools, 'create_shape')
const result = (await createShape.execute({
type: 'RECTANGLE',
x: 10,
@ -50,7 +56,7 @@ describe('AI adapter', () => {
width: 100,
height: 50,
name: 'Test Rect'
})) as any
})) as { id: string; type: string; name: string }
expect(result.id).toBeTruthy()
expect(result.type).toBe('RECTANGLE')
@ -67,7 +73,7 @@ describe('AI adapter', () => {
const rect = figma.createRectangle()
rect.resize(100, 100)
const setFill = tools.set_fill as { execute: Function }
const setFill = adapterTool(tools, 'set_fill')
await setFill.execute({ id: rect.id, color: '#00ff00' })
const fills = figma.getNodeById(rect.id)!.fills
@ -84,8 +90,8 @@ describe('AI adapter', () => {
rect.resize(50, 50)
frame.appendChild(rect)
const getTree = tools.get_page_tree as { execute: Function }
const result = (await getTree.execute({})) as any
const getTree = adapterTool(tools, 'get_page_tree')
const result = (await getTree.execute({})) as { page: unknown; children: unknown[] }
expect(result.page).toBeTruthy()
expect(result.children.length).toBeGreaterThan(0)
})
@ -105,7 +111,7 @@ describe('AI adapter', () => {
{ v, valibotSchema, tool }
)
const listPages = tools.list_pages as { execute: Function }
const listPages = adapterTool(tools, 'list_pages')
await listPages.execute({})
expect(calls).toEqual(['before', 'after'])
@ -127,7 +133,7 @@ describe('AI adapter', () => {
{ v, valibotSchema, tool }
)
const evalTool = tools.eval as { execute: Function }
const evalTool = adapterTool(tools, 'eval')
try {
await evalTool.execute({ code: 'throw new Error("test")' })
} catch {
@ -143,8 +149,8 @@ describe('AI adapter', () => {
figma.createText().name = 'Label'
figma.createRectangle().name = 'Button Secondary'
const findNodes = tools.find_nodes as { execute: Function }
const result = (await findNodes.execute({ name: 'button' })) as any
const findNodes = adapterTool(tools, 'find_nodes')
const result = (await findNodes.execute({ name: 'button' })) as { count: number }
expect(result.count).toBe(2)
})
@ -153,7 +159,7 @@ describe('AI adapter', () => {
const frame = figma.createFrame()
frame.resize(300, 200)
const setLayout = tools.set_layout as { execute: Function }
const setLayout = adapterTool(tools, 'set_layout')
await setLayout.execute({
id: frame.id,
direction: 'HORIZONTAL',
@ -169,10 +175,10 @@ describe('AI adapter', () => {
test('render JSX works through adapter', async () => {
const { tools } = setup()
const render = tools.render as { execute: Function }
const render = adapterTool(tools, 'render')
const result = (await render.execute({
jsx: '<Frame name="Card" w={200} h={100}><Text>Hello</Text></Frame>'
})) as any
})) as { name: string; type: string }
expect(result.name).toBe('Card')
expect(result.type).toBe('FRAME')
})
@ -182,11 +188,11 @@ describe('AI adapter', () => {
const rect = figma.createRectangle()
const id = rect.id
const deleteTool = tools.delete_node as { execute: Function }
const deleteTool = adapterTool(tools, 'delete_node')
await deleteTool.execute({ id })
const getNode = tools.get_node as { execute: Function }
const result = (await getNode.execute({ id })) as any
const getNode = adapterTool(tools, 'get_node')
const result = (await getNode.execute({ id })) as { error: string }
expect(result.error).toContain('not found')
})
})

View file

@ -23,8 +23,8 @@ async function evalCode(
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
}
function parseJSON(stdout: string): unknown {
return JSON.parse(stdout)
function parseJSON<T>(stdout: string): T {
return JSON.parse(stdout) as T
}
heavy('CLI tool operations via eval', () => {
@ -38,7 +38,7 @@ heavy('CLI tool operations via eval', () => {
return r.toJSON()
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
const result = parseJSON<Record<string, unknown>>(stdout)
expect(result.name).toBe('TestRect')
expect(result.x).toBe(100)
expect(result.y).toBe(200)
@ -54,7 +54,7 @@ heavy('CLI tool operations via eval', () => {
return { fills: r.fills }
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
const result = parseJSON<Record<string, unknown>>(stdout)
expect(result.fills.length).toBe(1)
expect(result.fills[0].color.r).toBe(1)
})
@ -76,7 +76,7 @@ heavy('CLI tool operations via eval', () => {
}
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
const result = parseJSON<Record<string, unknown>>(stdout)
expect(result.layoutMode).toBe('VERTICAL')
expect(result.itemSpacing).toBe(16)
expect(result.paddingLeft).toBe(20)
@ -91,7 +91,7 @@ heavy('CLI tool operations via eval', () => {
return { name: comp.name, type: comp.type }
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
const result = parseJSON<Record<string, unknown>>(stdout)
expect(result.name).toBe('Button')
expect(result.type).toBe('COMPONENT')
})
@ -110,7 +110,7 @@ heavy('CLI tool operations via eval', () => {
return { groupType, childCount, ungroupedExists: ungrouped !== null }
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
const result = parseJSON<Record<string, unknown>>(stdout)
expect(result.groupType).toBe('GROUP')
expect(result.childCount).toBe(2)
expect(result.ungroupedExists).toBe(false)
@ -122,7 +122,7 @@ heavy('CLI tool operations via eval', () => {
return { count: texts.length, hasTexts: texts.length > 0 }
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
const result = parseJSON<Record<string, unknown>>(stdout)
expect(result.hasTexts).toBe(true)
expect(result.count).toBeGreaterThan(0)
})
@ -140,7 +140,7 @@ heavy('CLI tool operations via eval', () => {
}
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
const result = parseJSON<Record<string, unknown>>(stdout)
expect(result.same).toBe(false)
expect(result.cloneName).toBe('Original')
expect(result.cloneWidth).toBe(100)
@ -159,7 +159,7 @@ heavy('CLI tool operations via eval', () => {
}
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
const result = parseJSON<Record<string, unknown>>(stdout)
expect(result.isChild).toBe(true)
})
@ -171,7 +171,7 @@ heavy('CLI tool operations via eval', () => {
return r.constraints
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
const result = parseJSON<Record<string, unknown>>(stdout)
expect(result.horizontal).toBe('CENTER')
expect(result.vertical).toBe('STRETCH')
})
@ -191,7 +191,7 @@ heavy('CLI tool operations via eval', () => {
return { count: f.effects.length, type: f.effects[0].type }
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
const result = parseJSON<Record<string, unknown>>(stdout)
expect(result.count).toBe(1)
expect(result.type).toBe('DROP_SHADOW')
})
@ -203,7 +203,7 @@ heavy('CLI tool operations via eval', () => {
return { variables: vars.length, collections: cols.length }
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
const result = parseJSON<Record<string, unknown>>(stdout)
expect(typeof result.variables).toBe('number')
expect(typeof result.collections).toBe('number')
})
@ -216,7 +216,7 @@ heavy('CLI tool operations via eval', () => {
return { page: figma.currentPage.name, pageCount: pages.length }
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
const result = parseJSON<Record<string, unknown>>(stdout)
expect(result.page).toBeTruthy()
expect(result.pageCount).toBeGreaterThanOrEqual(1)
})

View file

@ -2,6 +2,16 @@ import { describe, expect, test } from 'bun:test'
import { ALL_TOOLS, FigmaAPI, SceneGraph, computeAllLayouts } from '@open-pencil/core'
interface ToolResult {
id: string
name?: string
type?: string
error?: string
count?: number
nodes?: Array<{ id?: string; name: string; type: string }>
[key: string]: unknown
}
function setup() {
const graph = new SceneGraph()
const figma = new FigmaAPI(graph)
@ -44,7 +54,7 @@ describe('create_shape', () => {
width: 300,
height: 400,
name: 'Test Frame'
}) as any
}) as ToolResult
expect(result.name).toBe('Test Frame')
expect(result.type).toBe('FRAME')
@ -65,7 +75,7 @@ describe('create_shape', () => {
width: 500,
height: 500,
name: 'Parent'
}) as any
}) as ToolResult
const child = tool.execute(figma, {
type: 'RECTANGLE',
x: 10,
@ -73,7 +83,7 @@ describe('create_shape', () => {
width: 50,
height: 50,
parent_id: parent.id
}) as any
}) as ToolResult
const parentNode = figma.getNodeById(parent.id)!
expect(parentNode.children.some((c) => c.id === child.id)).toBe(true)
@ -99,7 +109,7 @@ describe('set_fill', () => {
test('returns error for missing node', () => {
const { figma } = setup()
const tool = ALL_TOOLS.find((t) => t.name === 'set_fill')!
const result = tool.execute(figma, { id: 'nonexistent', color: '#ff0000' }) as any
const result = tool.execute(figma, { id: 'nonexistent', color: '#ff0000' }) as ToolResult
expect(result.error).toContain('not found')
})
})
@ -170,7 +180,7 @@ describe('update_node', () => {
width: 200,
height: 150,
opacity: 0.5
}) as any
}) as ToolResult
expect(result.updated).toContain('x')
expect(result.updated).toContain('size')
@ -299,7 +309,7 @@ describe('clone_node', () => {
rect.resize(100, 100)
const tool = ALL_TOOLS.find((t) => t.name === 'clone_node')!
const result = tool.execute(figma, { id: rect.id }) as any
const result = tool.execute(figma, { id: rect.id }) as ToolResult
expect(result.id).not.toBe(rect.id)
expect(result.name).toBe('Original')
@ -342,7 +352,7 @@ describe('group_nodes', () => {
r2.resize(50, 50)
const tool = ALL_TOOLS.find((t) => t.name === 'group_nodes')!
const result = tool.execute(figma, { ids: [r1.id, r2.id] }) as any
const result = tool.execute(figma, { ids: [r1.id, r2.id] }) as ToolResult
expect(result.type).toBe('GROUP')
const group = figma.getNodeById(result.id)!
@ -359,7 +369,7 @@ describe('find_nodes', () => {
text.name = 'Label'
const tool = ALL_TOOLS.find((t) => t.name === 'find_nodes')!
const result = tool.execute(figma, { name: 'button' }) as any
const result = tool.execute(figma, { name: 'button' }) as ToolResult
expect(result.count).toBe(1)
expect(result.nodes[0].name).toBe('Button Primary')
})
@ -371,7 +381,7 @@ describe('find_nodes', () => {
figma.createText()
const tool = ALL_TOOLS.find((t) => t.name === 'find_nodes')!
const result = tool.execute(figma, { type: 'RECTANGLE' }) as any
const result = tool.execute(figma, { type: 'RECTANGLE' }) as ToolResult
expect(result.count).toBe(2)
})
})
@ -388,9 +398,9 @@ describe('query_nodes', () => {
figma.createRectangle()
const tool = ALL_TOOLS.find((t) => t.name === 'query_nodes')!
const result = (await tool.execute(figma, { selector: '//FRAME' })) as any
const result = (await tool.execute(figma, { selector: '//FRAME' })) as ToolResult
expect(result.count).toBe(2)
expect(result.nodes.every((n: any) => n.type === 'FRAME')).toBe(true)
expect(result.nodes?.every((n) => n.type === 'FRAME')).toBe(true)
})
test('finds by attribute //RECTANGLE[@width < 200]', async () => {
@ -403,7 +413,9 @@ describe('query_nodes', () => {
big.name = 'Big'
const tool = ALL_TOOLS.find((t) => t.name === 'query_nodes')!
const result = (await tool.execute(figma, { selector: '//RECTANGLE[@width < 200]' })) as any
const result = (await tool.execute(figma, {
selector: '//RECTANGLE[@width < 200]'
})) as ToolResult
expect(result.count).toBe(1)
expect(result.nodes[0].name).toBe('Small')
})
@ -420,15 +432,15 @@ describe('query_nodes', () => {
const tool = ALL_TOOLS.find((t) => t.name === 'query_nodes')!
const result = (await tool.execute(figma, {
selector: '//TEXT[contains(@name, "Label")]'
})) as any
})) as ToolResult
expect(result.count).toBe(2)
expect(result.nodes.every((n: any) => n.name.includes('Label'))).toBe(true)
expect(result.nodes?.every((n) => n.name.includes('Label'))).toBe(true)
})
test('returns error for invalid xpath', async () => {
const { figma } = setup()
const tool = ALL_TOOLS.find((t) => t.name === 'query_nodes')!
const result = (await tool.execute(figma, { selector: '///invalid[[[[' })) as any
const result = (await tool.execute(figma, { selector: '///invalid[[[[' })) as ToolResult
expect(result.error).toBeTruthy()
expect(result.error).toContain('XPath error')
})
@ -441,7 +453,7 @@ describe('query_nodes', () => {
}
const tool = ALL_TOOLS.find((t) => t.name === 'query_nodes')!
const result = (await tool.execute(figma, { selector: '//RECTANGLE', limit: 3 })) as any
const result = (await tool.execute(figma, { selector: '//RECTANGLE', limit: 3 })) as ToolResult
expect(result.count).toBe(3)
})
@ -450,7 +462,7 @@ describe('query_nodes', () => {
figma.createRectangle()
const tool = ALL_TOOLS.find((t) => t.name === 'query_nodes')!
const result = (await tool.execute(figma, { selector: '//ELLIPSE' })) as any
const result = (await tool.execute(figma, { selector: '//ELLIPSE' })) as ToolResult
expect(result.count).toBe(0)
expect(result.nodes).toEqual([])
})
@ -464,7 +476,7 @@ describe('get_node', () => {
rect.resize(100, 50)
const tool = ALL_TOOLS.find((t) => t.name === 'get_node')!
const result = tool.execute(figma, { id: rect.id }) as any
const result = tool.execute(figma, { id: rect.id }) as ToolResult
expect(result.name).toBe('Test Rect')
expect(result.width).toBe(100)
expect(result.height).toBe(50)
@ -475,7 +487,7 @@ describe('page tools', () => {
test('list_pages returns pages', () => {
const { figma } = setup()
const tool = ALL_TOOLS.find((t) => t.name === 'list_pages')!
const result = tool.execute(figma, {}) as any
const result = tool.execute(figma, {}) as ToolResult
expect(result.pages.length).toBeGreaterThanOrEqual(1)
})
@ -555,7 +567,7 @@ describe('set_font_range', () => {
y: 0,
width: 200,
height: 20
}) as any
}) as ToolResult
setText.execute(figma, { id: created.id, text: 'Hello World' })
setFontRange.execute(figma, {
id: created.id,
@ -591,7 +603,7 @@ describe('set_font_range', () => {
y: 0,
width: 200,
height: 20
}) as any
}) as ToolResult
setText.execute(figma, { id: created.id, text: 'Red text' })
setFontRange.execute(figma, { id: created.id, start: 0, end: 3, color: '#ff0000' })
@ -608,7 +620,7 @@ describe('render', () => {
const tool = ALL_TOOLS.find((t) => t.name === 'render')!
const result = (await tool.execute(figma, {
jsx: '<Frame name="Card" w={200} h={100} bg="#FFF"><Text>Hello</Text></Frame>'
})) as any
})) as ToolResult
expect(result.name).toBe('Card')
expect(result.type).toBe('FRAME')
expect(result.children.length).toBeGreaterThan(0)

View file

@ -383,7 +383,7 @@ describe('Doc 01/03 — Runtime Behavior Verification', () => {
spread: 0
}
]
} as any)
})
// Verify the node was created with effects
expect(node.effects).toHaveLength(1)
@ -394,11 +394,11 @@ describe('Doc 01/03 — Runtime Behavior Verification', () => {
const origRenderEffects = renderer.renderEffects.bind(renderer)
const origDrawNodeFill = renderer.drawNodeFill.bind(renderer)
renderer.renderEffects = (...args: any[]) => {
renderer.renderEffects = (...args: Parameters<typeof renderer.renderEffects>) => {
callOrder.push(`effects:${args[4]}`)
origRenderEffects(...args)
}
renderer.drawNodeFill = (...args: any[]) => {
renderer.drawNodeFill = (...args: Parameters<typeof renderer.drawNodeFill>) => {
callOrder.push('fill')
origDrawNodeFill(...args)
}
@ -431,7 +431,7 @@ describe('Doc 01/03 — Runtime Behavior Verification', () => {
color: { r: 0, g: 0, b: 0, a: 1 }
}
]
} as any
}
const overflow = renderer.effectOverflow(node)
// radius 10 + offset 5 = 15 margin needed
@ -463,14 +463,14 @@ describe('Doc 01/03 — Runtime Behavior Verification', () => {
spread: 0
}
]
} as any)
})
expect(textNode.effects).toHaveLength(1)
expect(textNode.effects[0].type).toBe('INNER_SHADOW')
// Verify the node exists and renderEffects('front') will be called
// The actual rendering is verified by the behavioral test
const effectTypes = textNode.effects.map((e: any) => e.type)
const effectTypes = textNode.effects.map((e) => e.type)
expect(effectTypes).toContain('INNER_SHADOW')
})
@ -494,7 +494,7 @@ describe('Doc 01/03 — Runtime Behavior Verification', () => {
spread: 0
}
]
} as any)
})
const child = graph.createNode('RECTANGLE', parentWithFill.id, {
x: 25,
@ -502,12 +502,12 @@ describe('Doc 01/03 — Runtime Behavior Verification', () => {
width: 50,
height: 50,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, visible: true, opacity: 1 }]
} as any)
})
// renderEffects should use the parent, not the child
// We verify this by checking that the canvas translation matches
// the node's own coordinates (shadowShapeChild would be null)
expect(parentWithFill.fills.some((f: any) => f.visible)).toBe(true)
expect(parentWithFill.fills.some((f) => f.visible)).toBe(true)
// renderShapeUncached passes getShadowShapeChild result to renderEffects
// which returns null when fills are visible → confirms claim
})

View file

@ -6,6 +6,8 @@ import { SkiaRenderer } from '#core/canvas'
import { SceneGraph } from '#core/scene-graph'
import { fontManager } from '#core/text'
import type { SceneNode } from '#core/scene-graph'
async function main() {
const ck = await initCanvasKit()
@ -50,9 +52,9 @@ async function main() {
spread: 0
}
]
}
} satisfies Partial<SceneNode>
const textNode = graph.createNode('TEXT', pageId, textProps as any)
const textNode = graph.createNode('TEXT', pageId, textProps)
const nodeId = textNode.id
const surface = ck.MakeSurface(width, height)!

View file

@ -7,6 +7,8 @@ import { renderNodesToImage } from '#core/io/formats/raster'
import { SceneGraph } from '#core/scene-graph'
import { fontManager } from '#core/text'
import type { SceneNode } from '#core/scene-graph'
interface TestCase {
text: string
fontSize: number
@ -194,9 +196,9 @@ async function main() {
spread: tc.spread
}
]
}
} satisfies Partial<SceneNode>
const textNode = graph.createNode('TEXT', pageId, textProps as any)
const textNode = graph.createNode('TEXT', pageId, textProps)
const surfW = Math.ceil(textProps.width) + 40
const surfH = Math.ceil(textProps.height) + 40

View file

@ -7,6 +7,8 @@ import { renderNodesToImage } from '#core/io/formats/raster'
import { SceneGraph } from '#core/scene-graph'
import { fontManager } from '#core/text'
import type { SceneNode } from '#core/scene-graph'
async function main() {
const ck = await initCanvasKit()
@ -49,9 +51,9 @@ async function main() {
spread: 0
}
]
}
} satisfies Partial<SceneNode>
const textNode = graph.createNode('TEXT', pageId, textProps as any)
const textNode = graph.createNode('TEXT', pageId, textProps)
const nodeId = textNode.id
const surface = ck.MakeSurface(800, 300)!

View file

@ -7,7 +7,7 @@ export function getSelectedIds(page: Page) {
export function getPageChildren(page: Page) {
return page.evaluate(() => {
const store = window.__OPEN_PENCIL_STORE__!
return store.graph.getChildren(store.state.currentPageId).map((n: any) => ({
return store.graph.getChildren(store.state.currentPageId).map((n) => ({
id: n.id,
type: n.type,
x: n.x,

View file

@ -1,9 +1,12 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import process from 'node:process'
import tailwindcss from '@tailwindcss/vite'
import Icons from 'unplugin-icons/vite'
import vue from '@vitejs/plugin-vue'
import IconsResolver from 'unplugin-icons/resolver'
import Icons from 'unplugin-icons/vite'
import Components from 'unplugin-vue-components/vite'
import { defineConfig } from 'vite'
import { createOpenPencilAliases } from './vite/aliases'
import { localAutomationToken, openPencilAutomationPlugin } from './vite/automation'
import { copyCanvasKitAssetsPlugin } from './vite/canvaskit-assets'
@ -11,7 +14,6 @@ import { openPencilPwaPlugin } from './vite/pwa'
import { rawMarkdownPlugin } from './vite/raw-markdown'
import { createDevServerOptions } from './vite/server'
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST
export default defineConfig(async ({ command }) => ({