Fix hit-testing: scope hover to current page, click-through empty containers
- Scope hover hit-test to current page (was searching all pages including internal component page, causing ghost hover outlines) - Frames/sections without visible fills or strokes are click-through - Groups are always click-through (only children are hittable) - Clipping parents reject hits outside their bounds - Instances/components check children before falling back to fill check - Switch test fixtures from material3.fig (55MB) / nuxtui.fig (82MB) to gold-preview.fig (537KB) for fast dev runs, gate heavy fixtures behind BUN_HEAVY_TESTS env var (enabled in CI)
This commit is contained in:
parent
69c8096c44
commit
38e7fdd4ac
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -39,6 +39,8 @@ jobs:
|
|||
|
||||
- name: Unit tests
|
||||
run: bun test tests/engine/
|
||||
env:
|
||||
BUN_HEAVY_TESTS: '1'
|
||||
|
||||
- name: Copy-paste detection
|
||||
run: bun run test:dupes
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
|
||||
### Fixes
|
||||
|
||||
- Fix hover highlighting nodes from internal component pages — scope hit-test to current page
|
||||
- Fix hit-testing on transparent frames and groups — empty containers without fills or strokes are now click-through, clipping parents reject hits outside their bounds, matching Figma behavior
|
||||
- Fix instance overrides on .fig import and clipboard paste — resolve guidPaths by overrideKey, handle component swaps (`overriddenSymbolID`), propagate through nested clone chains. Import and paste now share a single override engine.
|
||||
- Apply Figma component property assignments on import — boolean visibility toggles and instance swaps via `componentPropRefs`/`componentPropAssignments`
|
||||
- Apply `derivedSymbolData` sizes on import — containers now shrink correctly when component properties hide children
|
||||
|
|
|
|||
|
|
@ -772,6 +772,13 @@ export class SceneGraph {
|
|||
|
||||
private static readonly OPAQUE_CONTAINER_TYPES = new Set<NodeType>(['COMPONENT', 'INSTANCE'])
|
||||
|
||||
private static hasVisibleFillOrStroke(node: SceneNode): boolean {
|
||||
return (
|
||||
node.fills.some((f) => f.visible) ||
|
||||
node.strokes.some((s) => s.visible)
|
||||
)
|
||||
}
|
||||
|
||||
private hitTestChildren(
|
||||
px: number,
|
||||
py: number,
|
||||
|
|
@ -783,6 +790,12 @@ export class SceneGraph {
|
|||
const parent = this.nodes.get(parentId)
|
||||
if (!parent) return null
|
||||
|
||||
if (parent.clipsContent) {
|
||||
if (px < offsetX || px > offsetX + parent.width || py < offsetY || py > offsetY + parent.height) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse order = topmost first
|
||||
for (let i = parent.childIds.length - 1; i >= 0; i--) {
|
||||
const childId = parent.childIds[i]
|
||||
|
|
@ -792,18 +805,28 @@ export class SceneGraph {
|
|||
const ax = offsetX + child.x
|
||||
const ay = offsetY + child.y
|
||||
|
||||
// Components/instances: don't recurse unless in deep mode (double-click)
|
||||
if (SceneGraph.OPAQUE_CONTAINER_TYPES.has(child.type) && !deep) {
|
||||
if (px >= ax && px <= ax + child.width && py >= ay && py <= ay + child.height) {
|
||||
return child
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check children first (deeper hit)
|
||||
if (CONTAINER_TYPES.has(child.type)) {
|
||||
// Components/instances: don't recurse unless in deep mode (double-click).
|
||||
// Still check fills — empty instances are click-through like frames.
|
||||
if (SceneGraph.OPAQUE_CONTAINER_TYPES.has(child.type) && !deep) {
|
||||
if (px >= ax && px <= ax + child.width && py >= ay && py <= ay + child.height) {
|
||||
const childHit = this.hitTestChildren(px, py, childId, ax, ay, deep)
|
||||
if (childHit) return child
|
||||
if (SceneGraph.hasVisibleFillOrStroke(child)) return child
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const deepHit = this.hitTestChildren(px, py, childId, ax, ay, deep)
|
||||
if (deepHit) return deepHit
|
||||
|
||||
// Groups are always click-through (only children are hittable).
|
||||
// Frames/sections without visible fills or strokes are also click-through.
|
||||
if (child.type === 'GROUP') continue
|
||||
if (px >= ax && px <= ax + child.width && py >= ay && py <= ay + child.height) {
|
||||
if (SceneGraph.hasVisibleFillOrStroke(child)) return child
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (px >= ax && px <= ax + child.width && py >= ay && py <= ay + child.height) {
|
||||
|
|
|
|||
|
|
@ -561,7 +561,7 @@ export function useCanvasInput(
|
|||
cursorOverride.value = cursor
|
||||
|
||||
const hit =
|
||||
hitTestSectionTitle(cx, cy) ?? hitTestComponentLabel(cx, cy) ?? store.graph.hitTest(cx, cy)
|
||||
hitTestSectionTitle(cx, cy) ?? hitTestComponentLabel(cx, cy) ?? store.graph.hitTest(cx, cy, store.state.currentPageId)
|
||||
store.setHoveredNode(hit && !store.state.selectedIds.has(hit.id) ? hit.id : null)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { describe, expect, test, setDefaultTimeout } from 'bun:test'
|
||||
|
||||
setDefaultTimeout(30_000)
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { heavy } from '../helpers/test-utils'
|
||||
|
||||
setDefaultTimeout(30_000)
|
||||
|
||||
const CLI = join(import.meta.dir, '../../packages/cli/src/index.ts')
|
||||
const FIXTURE = join(import.meta.dir, '../fixtures/material3.fig')
|
||||
const FIXTURE = join(import.meta.dir, '../fixtures/gold-preview.fig')
|
||||
|
||||
async function run(args: string[], stdin?: string): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(['bun', CLI, ...args], {
|
||||
|
|
@ -22,7 +23,7 @@ async function run(args: string[], stdin?: string): Promise<{ stdout: string; st
|
|||
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
|
||||
}
|
||||
|
||||
describe('eval CLI', () => {
|
||||
heavy('eval CLI', () => {
|
||||
test('returns page name', async () => {
|
||||
const { stdout, exitCode } = await run(['eval', FIXTURE, '--code', 'return figma.currentPage.name'])
|
||||
expect(exitCode).toBe(0)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { initCodec } from '../../packages/core/src/kiwi/codec'
|
|||
import { SceneGraph } from '../../packages/core/src/scene-graph'
|
||||
|
||||
import type { SceneNode, NodeType, Fill } from '../../packages/core/src/scene-graph'
|
||||
import { heavy } from '../helpers/test-utils'
|
||||
|
||||
setDefaultTimeout(60_000)
|
||||
|
||||
|
|
@ -60,80 +61,55 @@ function countByType(nodes: SceneNode[]): Map<NodeType, number> {
|
|||
return counts
|
||||
}
|
||||
|
||||
// Parse fixtures once — they're large
|
||||
let material3: SceneGraph
|
||||
let nuxtui: SceneGraph
|
||||
let material3Nodes: SceneNode[]
|
||||
let nuxtUiNodes: SceneNode[]
|
||||
let parsed: SceneGraph
|
||||
let allNodes: SceneNode[]
|
||||
|
||||
beforeAll(async () => {
|
||||
const m3Buf = readFileSync(resolve(FIXTURES, 'material3.fig'))
|
||||
const nuBuf = readFileSync(resolve(FIXTURES, 'nuxtui.fig'))
|
||||
material3 = await parseFigFile(m3Buf.buffer as ArrayBuffer)
|
||||
nuxtui = await parseFigFile(nuBuf.buffer as ArrayBuffer)
|
||||
material3Nodes = collectAllNodes(material3)
|
||||
nuxtUiNodes = collectAllNodes(nuxtui)
|
||||
const buf = readFileSync(resolve(FIXTURES, 'gold-preview.fig'))
|
||||
parsed = await parseFigFile(buf.buffer as ArrayBuffer)
|
||||
allNodes = collectAllNodes(parsed)
|
||||
})
|
||||
|
||||
describe('parse real .fig files', () => {
|
||||
test('material3.fig parses without error', () => {
|
||||
expect(material3).toBeInstanceOf(SceneGraph)
|
||||
test('parses without error', () => {
|
||||
expect(parsed).toBeInstanceOf(SceneGraph)
|
||||
})
|
||||
|
||||
test('nuxtui.fig parses without error', () => {
|
||||
expect(nuxtui).toBeInstanceOf(SceneGraph)
|
||||
test('has pages', () => {
|
||||
expect(parsed.getPages().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('material3.fig has pages', () => {
|
||||
expect(material3.getPages().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('material3.fig has nodes', () => {
|
||||
expect(material3Nodes.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('nuxtui.fig has pages', () => {
|
||||
expect(nuxtui.getPages().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('nuxtui.fig has nodes', () => {
|
||||
expect(nuxtUiNodes.length).toBeGreaterThan(0)
|
||||
test('has nodes', () => {
|
||||
expect(allNodes.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('node type coverage', () => {
|
||||
test('material3: contains FRAME nodes', () => {
|
||||
expect(material3Nodes.some((n) => n.type === 'FRAME')).toBe(true)
|
||||
test('contains FRAME nodes', () => {
|
||||
expect(allNodes.some((n) => n.type === 'FRAME')).toBe(true)
|
||||
})
|
||||
|
||||
test('material3: contains TEXT nodes with content', () => {
|
||||
const textNodes = material3Nodes.filter((n) => n.type === 'TEXT')
|
||||
test('contains TEXT nodes with content', () => {
|
||||
const textNodes = allNodes.filter((n) => n.type === 'TEXT')
|
||||
expect(textNodes.length).toBeGreaterThan(0)
|
||||
expect(textNodes.some((n) => n.text.length > 0)).toBe(true)
|
||||
})
|
||||
|
||||
test('material3: contains COMPONENT nodes', () => {
|
||||
expect(material3Nodes.some((n) => n.type === 'COMPONENT')).toBe(true)
|
||||
test('contains INSTANCE nodes referencing components', () => {
|
||||
const instances = allNodes.filter((n) => n.type === 'INSTANCE')
|
||||
expect(instances.length).toBeGreaterThan(0)
|
||||
expect(instances.some((n) => n.componentId)).toBe(true)
|
||||
})
|
||||
|
||||
test('material3: contains INSTANCE nodes', () => {
|
||||
expect(material3Nodes.some((n) => n.type === 'INSTANCE')).toBe(true)
|
||||
})
|
||||
|
||||
test('material3: no unmapped node types', () => {
|
||||
const invalid = material3Nodes.filter((n) => !VALID_NODE_TYPES.has(n.type))
|
||||
expect(invalid.map((n) => `${n.name}: ${n.type}`)).toEqual([])
|
||||
})
|
||||
|
||||
test('nuxtui: no unmapped node types', () => {
|
||||
const invalid = nuxtUiNodes.filter((n) => !VALID_NODE_TYPES.has(n.type))
|
||||
test('no unmapped node types', () => {
|
||||
const invalid = allNodes.filter((n) => !VALID_NODE_TYPES.has(n.type))
|
||||
expect(invalid.map((n) => `${n.name}: ${n.type}`)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('property integrity', () => {
|
||||
test('all nodes have finite dimensions', () => {
|
||||
for (const n of material3Nodes) {
|
||||
for (const n of allNodes) {
|
||||
expect(Number.isFinite(n.width)).toBe(true)
|
||||
expect(Number.isFinite(n.height)).toBe(true)
|
||||
expect(n.width).toBeGreaterThanOrEqual(0)
|
||||
|
|
@ -142,21 +118,21 @@ describe('property integrity', () => {
|
|||
})
|
||||
|
||||
test('all nodes have finite positions', () => {
|
||||
for (const n of material3Nodes) {
|
||||
for (const n of allNodes) {
|
||||
expect(Number.isFinite(n.x)).toBe(true)
|
||||
expect(Number.isFinite(n.y)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('all nodes have valid opacity', () => {
|
||||
for (const n of material3Nodes) {
|
||||
for (const n of allNodes) {
|
||||
expect(n.opacity).toBeGreaterThanOrEqual(0)
|
||||
expect(n.opacity).toBeLessThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
|
||||
test('TEXT nodes have fontFamily', () => {
|
||||
for (const n of material3Nodes) {
|
||||
for (const n of allNodes) {
|
||||
if (n.type === 'TEXT') {
|
||||
expect(typeof n.fontFamily).toBe('string')
|
||||
expect(n.fontFamily.length).toBeGreaterThan(0)
|
||||
|
|
@ -165,7 +141,7 @@ describe('property integrity', () => {
|
|||
})
|
||||
|
||||
test('TEXT nodes have valid fontSize', () => {
|
||||
for (const n of material3Nodes) {
|
||||
for (const n of allNodes) {
|
||||
if (n.type === 'TEXT') {
|
||||
expect(n.fontSize).toBeGreaterThan(0)
|
||||
}
|
||||
|
|
@ -186,7 +162,7 @@ describe('property integrity', () => {
|
|||
expect(a).toBeLessThanOrEqual(1)
|
||||
}
|
||||
}
|
||||
for (const n of material3Nodes) {
|
||||
for (const n of allNodes) {
|
||||
for (const fill of n.fills) {
|
||||
checkFill(fill, n.name)
|
||||
}
|
||||
|
|
@ -194,7 +170,7 @@ describe('property integrity', () => {
|
|||
})
|
||||
|
||||
test('effects have valid radius', () => {
|
||||
for (const n of material3Nodes) {
|
||||
for (const n of allNodes) {
|
||||
for (const e of n.effects) {
|
||||
expect(e.radius).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
|
|
@ -202,7 +178,7 @@ describe('property integrity', () => {
|
|||
})
|
||||
|
||||
test('layout nodes have valid spacing', () => {
|
||||
for (const n of material3Nodes) {
|
||||
for (const n of allNodes) {
|
||||
if (n.layoutMode !== 'NONE') {
|
||||
expect(Number.isFinite(n.itemSpacing)).toBe(true)
|
||||
expect(n.paddingTop).toBeGreaterThanOrEqual(0)
|
||||
|
|
@ -213,6 +189,67 @@ describe('property integrity', () => {
|
|||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
heavy('parse heavy .fig files', () => {
|
||||
let material3: SceneGraph
|
||||
let nuxtui: SceneGraph
|
||||
let material3Nodes: SceneNode[]
|
||||
let nuxtUiNodes: SceneNode[]
|
||||
|
||||
beforeAll(async () => {
|
||||
const m3Buf = readFileSync(resolve(FIXTURES, 'material3.fig'))
|
||||
const nuBuf = readFileSync(resolve(FIXTURES, 'nuxtui.fig'))
|
||||
material3 = await parseFigFile(m3Buf.buffer as ArrayBuffer)
|
||||
nuxtui = await parseFigFile(nuBuf.buffer as ArrayBuffer)
|
||||
material3Nodes = collectAllNodes(material3)
|
||||
nuxtUiNodes = collectAllNodes(nuxtui)
|
||||
})
|
||||
|
||||
test('material3.fig parses with pages and nodes', () => {
|
||||
expect(material3).toBeInstanceOf(SceneGraph)
|
||||
expect(material3.getPages().length).toBeGreaterThan(0)
|
||||
expect(material3Nodes.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('nuxtui.fig parses with pages and nodes', () => {
|
||||
expect(nuxtui).toBeInstanceOf(SceneGraph)
|
||||
expect(nuxtui.getPages().length).toBeGreaterThan(0)
|
||||
expect(nuxtUiNodes.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('material3: contains COMPONENT nodes', () => {
|
||||
expect(material3Nodes.some((n) => n.type === 'COMPONENT')).toBe(true)
|
||||
})
|
||||
|
||||
test('material3: no unmapped node types', () => {
|
||||
const invalid = material3Nodes.filter((n) => !VALID_NODE_TYPES.has(n.type))
|
||||
expect(invalid.map((n) => `${n.name}: ${n.type}`)).toEqual([])
|
||||
})
|
||||
|
||||
test('nuxtui: no unmapped node types', () => {
|
||||
const invalid = nuxtUiNodes.filter((n) => !VALID_NODE_TYPES.has(n.type))
|
||||
expect(invalid.map((n) => `${n.name}: ${n.type}`)).toEqual([])
|
||||
})
|
||||
|
||||
test('material3: fills have valid colors', () => {
|
||||
for (const n of material3Nodes) {
|
||||
for (const fill of n.fills) {
|
||||
if (fill.type === 'SOLID') {
|
||||
const { r, g, b, a } = fill.color
|
||||
expect(r).toBeGreaterThanOrEqual(0)
|
||||
expect(r).toBeLessThanOrEqual(1)
|
||||
expect(g).toBeGreaterThanOrEqual(0)
|
||||
expect(g).toBeLessThanOrEqual(1)
|
||||
expect(b).toBeGreaterThanOrEqual(0)
|
||||
expect(b).toBeLessThanOrEqual(1)
|
||||
expect(a).toBeGreaterThanOrEqual(0)
|
||||
expect(a).toBeLessThanOrEqual(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('nuxtui: fills have valid colors', () => {
|
||||
for (const n of nuxtUiNodes) {
|
||||
for (const fill of n.fills) {
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ describe('MCP server', () => {
|
|||
})
|
||||
|
||||
test('open_file loads a .fig file', async () => {
|
||||
const fixturePath = join(import.meta.dir, '..', 'fixtures', 'nuxtui.fig')
|
||||
const fixturePath = join(import.meta.dir, '..', 'fixtures', 'gold-preview.fig')
|
||||
const result = await client.callTool({ name: 'open_file', arguments: { path: fixturePath } })
|
||||
const data = parseResult(result) as { pages: { name: string }[]; currentPage: string }
|
||||
expect(data.pages.length).toBeGreaterThan(0)
|
||||
|
|
|
|||
|
|
@ -218,13 +218,12 @@ describe('MCP tool execution', () => {
|
|||
})
|
||||
|
||||
test('open and query .fig file', async () => {
|
||||
const data = await Bun.file('tests/fixtures/nuxtui.fig').arrayBuffer()
|
||||
const data = await Bun.file('tests/fixtures/gold-preview.fig').arrayBuffer()
|
||||
const { api } = await setupWithFile(data)
|
||||
const pages = findTool('list_pages').execute(api, {}) as { pages: { name: string }[] }
|
||||
expect(pages.pages.length).toBeGreaterThan(1)
|
||||
expect(pages.pages.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
findTool('switch_page').execute(api, { page: 'FOUNDATIONS' })
|
||||
const found = findTool('find_nodes').execute(api, { name: 'Button', type: 'COMPONENT' }) as { count: number }
|
||||
const found = findTool('find_nodes').execute(api, { type: 'INSTANCE' }) as { count: number }
|
||||
expect(found.count).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -326,3 +326,80 @@ describe('Variables', () => {
|
|||
expect(graph.resolveColorVariable('v1')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hitTest', () => {
|
||||
test('hits a rectangle', () => {
|
||||
const graph = new SceneGraph()
|
||||
const id = rect(graph, 'R', 10, 10, 50, 50)
|
||||
expect(graph.hitTest(35, 35, pageId(graph))?.id).toBe(id)
|
||||
})
|
||||
|
||||
test('misses empty space', () => {
|
||||
const graph = new SceneGraph()
|
||||
rect(graph, 'R', 10, 10, 50, 50)
|
||||
expect(graph.hitTest(200, 200, pageId(graph))).toBeNull()
|
||||
})
|
||||
|
||||
test('frame without fills is click-through', () => {
|
||||
const graph = new SceneGraph()
|
||||
graph.createNode('FRAME', pageId(graph), {
|
||||
name: 'Empty Frame',
|
||||
x: 0, y: 0, width: 200, height: 200, fills: [],
|
||||
})
|
||||
expect(graph.hitTest(100, 100, pageId(graph))).toBeNull()
|
||||
})
|
||||
|
||||
test('frame with visible fill is hittable', () => {
|
||||
const graph = new SceneGraph()
|
||||
const frame = graph.createNode('FRAME', pageId(graph), {
|
||||
name: 'Filled Frame',
|
||||
x: 0, y: 0, width: 200, height: 200,
|
||||
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' }],
|
||||
})
|
||||
expect(graph.hitTest(100, 100, pageId(graph))?.id).toBe(frame.id)
|
||||
})
|
||||
|
||||
test('group is always click-through', () => {
|
||||
const graph = new SceneGraph()
|
||||
const groupId = graph.createNode('GROUP', pageId(graph), {
|
||||
name: 'Group', x: 0, y: 0, width: 200, height: 200,
|
||||
}).id
|
||||
const childId = graph.createNode('RECTANGLE', groupId, {
|
||||
name: 'Child', x: 10, y: 10, width: 30, height: 30,
|
||||
}).id
|
||||
// Hit child through group
|
||||
expect(graph.hitTest(20, 20, pageId(graph))?.id).toBe(childId)
|
||||
// Miss in group's empty area
|
||||
expect(graph.hitTest(150, 150, pageId(graph))).toBeNull()
|
||||
})
|
||||
|
||||
test('clipsContent prevents hits outside parent bounds', () => {
|
||||
const graph = new SceneGraph()
|
||||
const frame = graph.createNode('FRAME', pageId(graph), {
|
||||
name: 'Clip Frame', x: 0, y: 0, width: 100, height: 100,
|
||||
clipsContent: true, fills: [],
|
||||
})
|
||||
const childId = graph.createNode('RECTANGLE', frame.id, {
|
||||
name: 'Overflow Child', x: 50, y: 50, width: 200, height: 200,
|
||||
}).id
|
||||
// Inside both frame and child — hit
|
||||
expect(graph.hitTest(75, 75, pageId(graph))?.id).toBe(childId)
|
||||
// Inside child but outside clipping frame — miss
|
||||
expect(graph.hitTest(150, 150, pageId(graph))).toBeNull()
|
||||
})
|
||||
|
||||
test('instance without fills is click-through in empty area', () => {
|
||||
const graph = new SceneGraph()
|
||||
const compId = graph.createNode('COMPONENT', pageId(graph), {
|
||||
name: 'Comp', x: 0, y: 0, width: 200, height: 200, fills: [],
|
||||
}).id
|
||||
graph.createNode('RECTANGLE', compId, {
|
||||
name: 'Inner', x: 10, y: 10, width: 30, height: 30,
|
||||
})
|
||||
const instId = graph.createInstance(compId, pageId(graph), { x: 300, y: 0 }).id
|
||||
// Hit on instance's child area — returns instance (opaque container)
|
||||
expect(graph.hitTest(320, 20, pageId(graph))?.id).toBe(instId)
|
||||
// Miss on instance's empty area (no fills)
|
||||
expect(graph.hitTest(450, 150, pageId(graph))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { describe, expect, test, setDefaultTimeout } from 'bun:test'
|
||||
import { join } from 'path'
|
||||
import { heavy } from '../helpers/test-utils'
|
||||
|
||||
setDefaultTimeout(30_000)
|
||||
import { join } from 'path'
|
||||
|
||||
const CLI = join(import.meta.dir, '../../packages/cli/src/index.ts')
|
||||
const FIXTURE = join(import.meta.dir, '../fixtures/material3.fig')
|
||||
const FIXTURE = join(import.meta.dir, '../fixtures/gold-preview.fig')
|
||||
|
||||
async function evalCode(
|
||||
code: string
|
||||
|
|
@ -25,7 +26,7 @@ function parseJSON(stdout: string): unknown {
|
|||
return JSON.parse(stdout)
|
||||
}
|
||||
|
||||
describe('CLI tool operations via eval', () => {
|
||||
heavy('CLI tool operations via eval', () => {
|
||||
test('create and read back a node', async () => {
|
||||
const { stdout, exitCode } = await evalCode(`
|
||||
const r = figma.createRectangle()
|
||||
|
|
|
|||
3
tests/helpers/test-utils.ts
Normal file
3
tests/helpers/test-utils.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { describe } from 'bun:test'
|
||||
|
||||
export const heavy = describe.if(!!process.env.BUN_HEAVY_TESTS)
|
||||
Loading…
Reference in a new issue