Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
fix(test): wait on canvas frame readiness instead of fixed sleeps
The canvas-breakpoint tests flaked in CI (Verify step of the release
workflow), failing reliably on a loaded runner while passing locally.

Root cause: the canvas reveals breakpoint frames progressively
(useProgressiveCanvasFrameLoading — the active frame after a
requestAnimationFrame, each inactive frame behind a chained
setTimeout(32ms) → requestIdleCallback({timeout:160ms})), then mounts each
frame's iframe and portals the page tree in. The failing tests all query
the `mobile` frame, which — with `desktop` active — is the FIRST inactive
frame, so it appears only after that whole async chain. The tests waited
with fixed budgets: a hardcoded setTimeout(90ms) "flush" and the default
1000ms waitFor. Those are calibrated for a fast laptop; under CI
event-loop saturation the timers drift past both, so the iframe content
isn't present when the assertion runs.

Proven by inflating the reveal delay to 1500ms locally, which reproduced
the exact CI signature (activation tests timing out ~1010ms, rendering
tests failing ~110ms); with this change the same simulation passes 17/17.

Fix: replace every arbitrary sleep with condition-based waiting. Add
shared waitForCanvasFrameDocument / waitForCanvasNodeInFrame /
waitForCanvasElement helpers in iframeCanvasQuery.ts that poll the real
readiness condition with a CI-tolerant 5s ceiling (waitFor returns the
instant the condition holds, so zero cost on a fast machine). This also
removes the flushProgressiveCanvasFrames helper that was copy-pasted
across four test files. Product code is untouched — the progressive
loader is correct; the tests were making brittle timing assumptions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
  • Loading branch information
DavidBabinec and claude committed Jun 13, 2026
commit b59b8ff04eb16dd76cd553e5e7aad273f3352e9d
30 changes: 10 additions & 20 deletions src/__tests__/canvas/breakpointActivation.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { DndContext } from '@dnd-kit/core'
import { CanvasRoot } from '@site/canvas/CanvasRoot'
import { useEditorStore } from '@site/store/store'
import { makeNode, makePage, makeSite } from '../fixtures'
import { getCanvasFrameDocument, queryCanvasNodeInFrame } from './iframeCanvasQuery'
import {
queryCanvasNodeInFrame,
waitForCanvasFrameDocument,
waitForCanvasNodeInFrame,
} from './iframeCanvasQuery'
import '@modules/base'

afterEach(cleanup)
Expand Down Expand Up @@ -32,7 +36,7 @@ beforeEach(() => {
describe('canvas breakpoint activation', () => {
it('activates an inactive breakpoint without changing the selected layer on first node click', async () => {
renderCanvas()
await waitForCanvasNode('mobile', 'other')
await waitForCanvasNodeInFrame('mobile', 'other')
const otherNode = queryCanvasNodeInFrame('mobile', 'other')
expect(otherNode).toBeTruthy()

Expand All @@ -48,7 +52,7 @@ describe('canvas breakpoint activation', () => {

it('shows a cursor-following activation tooltip over inactive breakpoint frames', async () => {
renderCanvas()
const mobileDoc = await waitForFrameDocument('mobile')
const mobileDoc = await waitForCanvasFrameDocument('mobile')

act(() => {
fireEvent.mouseMove(mobileDoc.body, { clientX: 24, clientY: 32 })
Expand All @@ -59,7 +63,7 @@ describe('canvas breakpoint activation', () => {

it('does not show the activation tooltip when no layer properties context is active', async () => {
renderCanvas({ selectedNodeId: null, selectedNodeIds: [] })
const mobileDoc = await waitForFrameDocument('mobile')
const mobileDoc = await waitForCanvasFrameDocument('mobile')

act(() => {
fireEvent.mouseMove(mobileDoc.body, { clientX: 24, clientY: 32 })
Expand All @@ -74,7 +78,7 @@ describe('canvas breakpoint activation', () => {
selectedNodeIds: ['selected'],
propertiesPanel: { collapsed: true, x: 0, y: 0, width: 360 },
})
await waitForCanvasNode('mobile', 'other')
await waitForCanvasNodeInFrame('mobile', 'other')
const otherNode = queryCanvasNodeInFrame('mobile', 'other')
expect(otherNode).toBeTruthy()

Expand Down Expand Up @@ -129,17 +133,3 @@ function renderCanvas(
)
}

async function waitForCanvasNode(breakpointId: string, nodeId: string): Promise<void> {
await waitFor(() => {
expect(queryCanvasNodeInFrame(breakpointId, nodeId)).toBeTruthy()
})
}

async function waitForFrameDocument(breakpointId: string): Promise<Document> {
let doc: Document | null = null
await waitFor(() => {
doc = getCanvasFrameDocument(breakpointId)
expect(doc?.body).toBeTruthy()
})
return doc!
}
42 changes: 17 additions & 25 deletions src/__tests__/canvas/breakpointProps.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,18 @@ import { readFileSync } from 'fs'
import { useEditorStore } from '@site/store/store'
import { BreakpointFrame } from '@site/canvas/BreakpointFrame'
import { CanvasRoot } from '@site/canvas/CanvasRoot'
import { getCanvasFrameDocument, queryCanvasNodeInFrame } from './iframeCanvasQuery'
import {
getCanvasFrameDocument,
queryCanvasNodeInFrame,
waitForCanvasFrameDocument,
waitForCanvasNodeInFrame,
} from './iframeCanvasQuery'
Comment on lines +8 to +13
import '@modules/base'

function renderCanvas() {
return render(<CanvasRoot />)
}

async function flushProgressiveCanvasFrames() {
await act(async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 90))
})
}

const BREAKPOINT_FRAME_CSS = new URL(
'../../admin/pages/site/canvas/BreakpointFrame.module.css',
import.meta.url,
Expand Down Expand Up @@ -91,13 +90,10 @@ describe('canvas breakpoint rendering', () => {
useEditorStore.getState().setActiveBreakpoint('desktop')

renderCanvas()
await flushProgressiveCanvasFrames()

const mobileNode = queryCanvasNodeInFrame('mobile', textId)
expect(mobileNode).toBeTruthy()
const mobileNode = await waitForCanvasNodeInFrame('mobile', textId)

act(() => {
fireEvent.click(mobileNode!)
fireEvent.click(mobileNode)
})

const state = useEditorStore.getState()
Expand All @@ -115,27 +111,23 @@ describe('canvas breakpoint rendering', () => {
}, page.rootNodeId)

renderCanvas()
await flushProgressiveCanvasFrames()

const mobileNode = queryCanvasNodeInFrame('mobile', textId)
const desktopNode = queryCanvasNodeInFrame('desktop', textId)
expect(mobileNode).toBeTruthy()
expect(desktopNode).toBeTruthy()
const mobileNode = await waitForCanvasNodeInFrame('mobile', textId)
const desktopNode = await waitForCanvasNodeInFrame('desktop', textId)

act(() => {
fireEvent.mouseEnter(mobileNode!)
fireEvent.mouseEnter(mobileNode)
})

expect(mobileNode!.getAttribute('data-hovered')).toBe('true')
expect(desktopNode!.hasAttribute('data-hovered')).toBe(false)
expect(mobileNode.getAttribute('data-hovered')).toBe('true')
expect(desktopNode.hasAttribute('data-hovered')).toBe(false)

act(() => {
fireEvent.mouseLeave(mobileNode!)
fireEvent.mouseEnter(desktopNode!)
fireEvent.mouseLeave(mobileNode)
fireEvent.mouseEnter(desktopNode)
})

expect(mobileNode!.hasAttribute('data-hovered')).toBe(false)
expect(desktopNode!.getAttribute('data-hovered')).toBe('true')
expect(mobileNode.hasAttribute('data-hovered')).toBe(false)
expect(desktopNode.getAttribute('data-hovered')).toBe('true')
})

it('dims inactive breakpoint frames only while editing a selected node in the open properties panel', () => {
Expand Down
19 changes: 5 additions & 14 deletions src/__tests__/canvas/canvasFormControls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,13 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { DndContext } from '@dnd-kit/core'
import { useEditorStore } from '@site/store/store'
import { CanvasRoot } from '@site/canvas/CanvasRoot'
import { queryCanvasNodeInFrame } from './iframeCanvasQuery'
import { waitForCanvasNodeInFrame } from './iframeCanvasQuery'
import '@modules/base'

function renderCanvas() {
return render(<DndContext><CanvasRoot /></DndContext>)
}

async function flushProgressiveCanvasFrames() {
await act(async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 90))
})
}

beforeEach(() => {
cleanup()
useEditorStore.setState({
Expand Down Expand Up @@ -58,23 +52,20 @@ describe('canvas form controls', () => {
}, formId)

renderCanvas()
await flushProgressiveCanvasFrames()

const input = queryCanvasNodeInFrame<HTMLInputElement>('desktop', inputId)
const select = queryCanvasNodeInFrame<HTMLSelectElement>('desktop', selectId)
expect(input).toBeTruthy()
expect(select).toBeTruthy()
const input = await waitForCanvasNodeInFrame<HTMLInputElement>('desktop', inputId)
const select = await waitForCanvasNodeInFrame<HTMLSelectElement>('desktop', selectId)

let inputMouseDown = true
await act(async () => {
inputMouseDown = fireEvent.mouseDown(input!)
inputMouseDown = fireEvent.mouseDown(input)
})
expect(inputMouseDown).toBe(false)
expect(useEditorStore.getState().selectedNodeId).toBe(inputId)

let selectMouseDown = true
await act(async () => {
selectMouseDown = fireEvent.pointerDown(select!)
selectMouseDown = fireEvent.pointerDown(select)
})
expect(selectMouseDown).toBe(false)
expect(useEditorStore.getState().selectedNodeId).toBe(selectId)
Expand Down
85 changes: 85 additions & 0 deletions src/__tests__/canvas/iframeCanvasQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,33 @@
* detail by walking every canvas iframe in the test DOM and returning the
* first match. Callers can therefore stay agnostic about whether the
* canvas renders directly or via iframes.
*
* Readiness is asynchronous. The canvas reveals breakpoint frames
* progressively (`useProgressiveCanvasFrameLoading`: the active frame after a
* `requestAnimationFrame`, each inactive frame behind a chained
* `setTimeout` → `requestIdleCallback`), and only then mounts each frame's
* iframe and portals the page tree into it. So a queried frame — especially a
* NON-active one — can take a few hundred milliseconds to appear, and longer
* on a loaded CI runner where those timers drift. Tests MUST therefore wait on
* the actual condition (the frame document / node being present) rather than
* sleeping a fixed duration: a hardcoded sleep that happens to be enough on a
* fast laptop is exactly what makes these tests flaky in CI. Use
* `waitForCanvasFrameDocument` / `waitForCanvasNodeInFrame` below.
*/

import { waitFor } from '@testing-library/react'
import { expect } from 'bun:test'

/**
* CI-tolerant ceiling for canvas-frame readiness. `waitFor` returns the instant
* the condition holds, so this adds no time on a fast machine — it only widens
* the headroom so a slow/contended CI runner's timer drift doesn't trip the
* default 1000 ms `waitFor` budget. The progressive reveal is bounded
* (rAF + setTimeout + a 160 ms idle-callback timeout per inactive frame), so a
* healthy canvas always settles well within this; an unhealthy one fails loudly
* here instead of hanging.
*/
export const CANVAS_FRAME_READY_TIMEOUT_MS = 5000

/**
* Find the first element matching `selector` inside any canvas iframe (or
Expand Down Expand Up @@ -86,3 +112,62 @@ function allCanvasIframes(): HTMLIFrameElement[] {
(i) => i.title.startsWith('Canvas frame for '),
)
}

/**
* Wait until a breakpoint frame's iframe document is mounted and ready, then
* return it. Replaces fixed-duration "flush progressive frames" sleeps — polls
* the real readiness condition so it is robust to the progressive reveal's
* timing on any machine. Throws (via `waitFor`) if the frame never appears.
*/
export async function waitForCanvasFrameDocument(breakpointId: string): Promise<Document> {
let doc: Document | null = null
await waitFor(
() => {
doc = getCanvasFrameDocument(breakpointId)
expect(doc?.body).toBeTruthy()
},
{ timeout: CANVAS_FRAME_READY_TIMEOUT_MS },
)
return doc!
}

/**
* Wait until `nodeId` is rendered inside the `breakpointId` frame's iframe,
* then return the element. The node-level companion to
* `waitForCanvasFrameDocument` for tests that immediately interact with a
* specific canvas node.
*/
export async function waitForCanvasNodeInFrame<E extends Element = HTMLElement>(
breakpointId: string,
nodeId: string,
): Promise<E> {
let el: E | null = null
await waitFor(
() => {
el = queryCanvasNodeInFrame<E>(breakpointId, nodeId)
expect(el).toBeTruthy()
},
{ timeout: CANVAS_FRAME_READY_TIMEOUT_MS },
)
return el!
}

/**
* Wait until `selector` matches in any canvas iframe (or the parent document),
* then return the first match. The frame-agnostic companion to
* `waitForCanvasNodeInFrame` for tests that don't care which breakpoint frame
* the element lands in.
*/
export async function waitForCanvasElement<E extends Element = HTMLElement>(
selector: string,
): Promise<E> {
let el: E | null = null
await waitFor(
() => {
el = queryCanvasElement<E>(selector)
expect(el).toBeTruthy()
},
{ timeout: CANVAS_FRAME_READY_TIMEOUT_MS },
)
return el!
}
Loading