snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
3
apps/examples/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.vercel
|
||||
# We don't need these because we only check linux snapshots in CI
|
||||
e2e/tests/*-snapshots/*darwin.png
|
||||
9
apps/examples/LICENSE.md
Executable file
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 tldraw Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
29
apps/examples/README.md
Executable file
@@ -0,0 +1,29 @@
|
||||
# Examples
|
||||
|
||||
Development environment and examples for the tldraw SDK. A live version of the examples is found here: [examples.tldraw.com](https://examples.tldraw.com)
|
||||
|
||||
If you're interested in contributing a new example, please see our [example writing guide](./writing-examples.md) for detailed guidelines on creating effective examples.
|
||||
|
||||
## Distributions
|
||||
|
||||
You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions).
|
||||
|
||||
## Contribution
|
||||
|
||||
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
|
||||
|
||||
## License
|
||||
|
||||
This project is provided under the MIT license found [here](https://github.com/tldraw/tldraw/blob/main/apps/examples/LICENSE.md).
|
||||
|
||||
## Trademarks
|
||||
|
||||
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
|
||||
|
||||
## Contact
|
||||
|
||||
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
|
||||
|
||||
## Community
|
||||
|
||||
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
|
||||
44
apps/examples/e2e-device/helpers/app.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Editor } from 'tldraw'
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
const NATIVE_CONTEXT = 'NATIVE_APP'
|
||||
|
||||
/**
|
||||
* Open the `/end-to-end` example (which exposes `window.editor`), wait for the
|
||||
* canvas, and reset to a known state: no animations, zoom at 1, no shapes.
|
||||
*/
|
||||
export async function openEditor() {
|
||||
const webContext = await currentWebContext()
|
||||
await browser.switchContext(webContext)
|
||||
|
||||
if (!(await browser.getUrl()).includes('end-to-end')) {
|
||||
await browser.url(`${browser.options.baseUrl}/end-to-end`)
|
||||
}
|
||||
await browser.$('.tl-canvas').waitForExist({ timeout: 30_000 })
|
||||
|
||||
await browser.execute(() => {
|
||||
editor.user.updateUserPreferences({ animationSpeed: 0 })
|
||||
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
|
||||
editor.setCurrentTool('select')
|
||||
editor.resetZoom()
|
||||
})
|
||||
}
|
||||
|
||||
/** Read the current zoom level from the live editor. */
|
||||
export function getZoom(): Promise<number> {
|
||||
return browser.execute(() => editor.getZoomLevel())
|
||||
}
|
||||
|
||||
/** Read the currently selected shape ids. */
|
||||
export function getSelectedShapeIds(): Promise<string[]> {
|
||||
return browser.execute(() => editor.getSelectedShapeIds())
|
||||
}
|
||||
|
||||
/** The webview context id changes per platform/run; find it rather than hardcode. */
|
||||
export async function currentWebContext(): Promise<string> {
|
||||
const contexts = (await browser.getContexts()) as string[]
|
||||
const web = contexts.find((c) => c !== NATIVE_CONTEXT)
|
||||
if (!web) throw new Error(`No web context found. Available: ${contexts.join(', ')}`)
|
||||
return web
|
||||
}
|
||||
118
apps/examples/e2e-device/helpers/pinch.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { currentWebContext } from './app'
|
||||
|
||||
const NATIVE_CONTEXT = 'NATIVE_APP'
|
||||
|
||||
/**
|
||||
* Two-finger pinch over the tldraw canvas.
|
||||
*
|
||||
* Why this is the way it is: on both iOS (XCUITest) and Android (UiAutomator2),
|
||||
* multi-touch W3C actions only operate in the NATIVE context, and only on native
|
||||
* screen coordinates. So we:
|
||||
*
|
||||
* 1. (web context) measure the canvas rect + viewport in CSS pixels,
|
||||
* 2. (native context) translate those to device coordinates and run two
|
||||
* simultaneous touch pointers from the centre outwards (or inwards),
|
||||
* 3. (web context) hand back so the caller can assert on `editor` state.
|
||||
*
|
||||
* The touches enter through the OS, so Safari/Chrome's own gesture recogniser
|
||||
* sees them — that is the whole point, and what synthetic DOM touch can't do.
|
||||
*
|
||||
* @param scale >1 spreads the fingers apart (zoom in), <1 brings them together
|
||||
* (zoom out).
|
||||
*/
|
||||
export async function pinchCanvas({ scale, steps = 12 }: { scale: number; steps?: number }) {
|
||||
const web = await currentWebContext()
|
||||
|
||||
// 1. Measure in the web context (CSS pixels relative to the layout viewport).
|
||||
await browser.switchContext(web)
|
||||
const rect = await browser.execute(() => {
|
||||
const el = document.querySelector('.tl-canvas')!
|
||||
const r = el.getBoundingClientRect()
|
||||
return {
|
||||
cx: r.left + r.width / 2,
|
||||
cy: r.top + r.height / 2,
|
||||
dpr: window.devicePixelRatio,
|
||||
// Offset of the web viewport inside the native window (browser chrome).
|
||||
// visualViewport.offsetTop is the cleanest signal we have for this.
|
||||
offsetTop: window.visualViewport?.offsetTop ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
// 2. Translate CSS px -> native device coordinates.
|
||||
//
|
||||
// iOS reports native coordinates in POINTS (already CSS-px-equivalent), so the
|
||||
// scale factor is 1 and we only add the top inset for the Safari toolbar.
|
||||
// Android UiAutomator2 reports PIXELS, so we multiply by devicePixelRatio.
|
||||
//
|
||||
// `TOP_INSET` is the one bit that may need a per-device tweak — the height of
|
||||
// the browser chrome above the web viewport. Calibrate once per device class.
|
||||
const isAndroid = browser.isAndroid
|
||||
const pxScale = isAndroid ? rect.dpr : 1
|
||||
const TOP_INSET = Number(process.env.DEVICE_TOP_INSET ?? (isAndroid ? 0 : 0))
|
||||
|
||||
const cx = rect.cx * pxScale
|
||||
const cy = (rect.cy + rect.offsetTop) * pxScale + TOP_INSET
|
||||
|
||||
// Fingers start 60px either side of centre, end at 60*scale either side.
|
||||
const startGap = 60 * pxScale
|
||||
const endGap = startGap * scale
|
||||
|
||||
const finger = (id: string, dir: -1 | 1) =>
|
||||
buildPinchPointer({
|
||||
id,
|
||||
cx,
|
||||
cy,
|
||||
startGap: dir * startGap,
|
||||
endGap: dir * endGap,
|
||||
steps,
|
||||
})
|
||||
|
||||
// 3. Perform the gesture natively, then return to the web context.
|
||||
await browser.switchContext(NATIVE_CONTEXT)
|
||||
await browser.performActions([finger('finger1', -1), finger('finger2', 1)])
|
||||
await browser.releaseActions()
|
||||
await browser.switchContext(web)
|
||||
}
|
||||
|
||||
/**
|
||||
* One touch pointer that presses at `cx + startGap`, then moves in `steps` hops
|
||||
* to `cx + endGap` along the x axis (so two mirrored pointers spread/contract).
|
||||
*/
|
||||
function buildPinchPointer({
|
||||
id,
|
||||
cx,
|
||||
cy,
|
||||
startGap,
|
||||
endGap,
|
||||
steps,
|
||||
}: {
|
||||
id: string
|
||||
cx: number
|
||||
cy: number
|
||||
startGap: number
|
||||
endGap: number
|
||||
steps: number
|
||||
}) {
|
||||
const actions: Array<Record<string, unknown>> = [
|
||||
{ type: 'pointerMove', duration: 0, x: Math.round(cx + startGap), y: Math.round(cy) },
|
||||
{ type: 'pointerDown', button: 0 },
|
||||
{ type: 'pause', duration: 50 },
|
||||
]
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const gap = startGap + ((endGap - startGap) * i) / steps
|
||||
actions.push({
|
||||
type: 'pointerMove',
|
||||
duration: 25,
|
||||
x: Math.round(cx + gap),
|
||||
y: Math.round(cy),
|
||||
})
|
||||
}
|
||||
actions.push({ type: 'pointerUp', button: 0 })
|
||||
|
||||
return {
|
||||
type: 'pointer',
|
||||
id,
|
||||
parameters: { pointerType: 'touch' },
|
||||
actions,
|
||||
}
|
||||
}
|
||||
70
apps/examples/e2e-device/helpers/stroke.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { currentWebContext } from './app'
|
||||
|
||||
const NATIVE_CONTEXT = 'NATIVE_APP'
|
||||
|
||||
/**
|
||||
* Draw a single-finger freehand stroke across the canvas through the real OS
|
||||
* touch pipeline (same native-context + coordinate-translation approach as
|
||||
* pinch.ts). `from`/`to` are CSS-pixel offsets relative to the canvas centre.
|
||||
*/
|
||||
export async function drawTouchStroke({
|
||||
from,
|
||||
to,
|
||||
steps = 12,
|
||||
}: {
|
||||
from: { x: number; y: number }
|
||||
to: { x: number; y: number }
|
||||
steps?: number
|
||||
}) {
|
||||
const web = await currentWebContext()
|
||||
|
||||
// Measure in the web context (CSS pixels relative to the layout viewport).
|
||||
await browser.switchContext(web)
|
||||
const rect = await browser.execute(() => {
|
||||
const el = document.querySelector('.tl-canvas')!
|
||||
const r = el.getBoundingClientRect()
|
||||
return {
|
||||
cx: r.left + r.width / 2,
|
||||
cy: r.top + r.height / 2,
|
||||
dpr: window.devicePixelRatio,
|
||||
offsetTop: window.visualViewport?.offsetTop ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
// CSS px -> native device coordinates (iOS reports points, Android pixels).
|
||||
const isAndroid = browser.isAndroid
|
||||
const pxScale = isAndroid ? rect.dpr : 1
|
||||
const TOP_INSET = Number(process.env.DEVICE_TOP_INSET ?? 0)
|
||||
const cx = rect.cx * pxScale
|
||||
const cy = (rect.cy + rect.offsetTop) * pxScale + TOP_INSET
|
||||
|
||||
const point = (p: { x: number; y: number }) => ({
|
||||
x: Math.round(cx + p.x * pxScale),
|
||||
y: Math.round(cy + p.y * pxScale),
|
||||
})
|
||||
|
||||
const start = point(from)
|
||||
const actions: Array<Record<string, unknown>> = [
|
||||
{ type: 'pointerMove', duration: 0, ...start },
|
||||
{ type: 'pointerDown', button: 0 },
|
||||
{ type: 'pause', duration: 50 },
|
||||
]
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
actions.push({
|
||||
type: 'pointerMove',
|
||||
duration: 25,
|
||||
...point({
|
||||
x: from.x + ((to.x - from.x) * i) / steps,
|
||||
y: from.y + ((to.y - from.y) * i) / steps,
|
||||
}),
|
||||
})
|
||||
}
|
||||
actions.push({ type: 'pointerUp', button: 0 })
|
||||
|
||||
await browser.switchContext(NATIVE_CONTEXT)
|
||||
await browser.performActions([
|
||||
{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions },
|
||||
])
|
||||
await browser.releaseActions()
|
||||
await browser.switchContext(web)
|
||||
}
|
||||
78
apps/examples/e2e-device/tests/draw-pressure-encoding.e2e.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { expect } from '@wdio/globals'
|
||||
import type { Editor } from 'tldraw'
|
||||
import { openEditor } from '../helpers/app'
|
||||
import { drawTouchStroke } from '../helpers/stroke'
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
/**
|
||||
* Guards the device-side contract behind 2D draw-shape encoding (#8963/#8879):
|
||||
* tldraw drops the constant pressure channel (z) from mouse/trackpad/finger
|
||||
* strokes, keyed off `isPenOrStylus` — which is inferred from the pressure the
|
||||
* BROWSER reports for the input device (Drawing.ts). The unit tests pick the
|
||||
* pressure values they feed in, so they verify the decision logic but not what
|
||||
* real engines actually report for a finger.
|
||||
*
|
||||
* That engine contract is the fragile part: 3D Touch iPhones really did report
|
||||
* varying force for plain finger touches. If an engine (re)introduces that,
|
||||
* finger strokes silently flip back to 3D — losing the storage win and making
|
||||
* stroke width track finger pressure — with no unit or Playwright test failing,
|
||||
* because those layers synthesize their own pressure values. Drawing through
|
||||
* the real OS touch pipeline and asserting the 2D encoding is the only
|
||||
* automated check that can catch it.
|
||||
*
|
||||
* Skips (rather than fails) on builds whose schema predates the segment `dim`
|
||||
* field, so the suite stays green until #8963 merges.
|
||||
*/
|
||||
describe('draw shape pressure encoding (#8963)', () => {
|
||||
beforeEach(async () => {
|
||||
await openEditor()
|
||||
})
|
||||
|
||||
it('a finger-drawn stroke uses the 2D (no-pressure) encoding', async function () {
|
||||
// Feature-detect the `dim` field: on builds that predate #8963 the segment
|
||||
// validator rejects it, so probe with a throwaway shape and skip if absent.
|
||||
const dimSupported = await browser.execute(() => {
|
||||
try {
|
||||
editor.createShapes([
|
||||
{
|
||||
id: 'shape:dimprobe',
|
||||
type: 'draw',
|
||||
props: { segments: [{ type: 'free', path: '', dim: 2 }] },
|
||||
},
|
||||
] as any)
|
||||
editor.deleteShapes(['shape:dimprobe'] as any)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
if (!dimSupported) this.skip()
|
||||
|
||||
// Block body: a concise arrow would implicitly return the (circular)
|
||||
// Editor instance, which can't be serialized over the WebDriver wire.
|
||||
await browser.execute(() => {
|
||||
editor.setCurrentTool('draw')
|
||||
})
|
||||
await drawTouchStroke({ from: { x: -80, y: -40 }, to: { x: 80, y: 40 } })
|
||||
|
||||
const segments = await browser.execute(() => {
|
||||
const shape = editor.getCurrentPageShapes().find((s) => s.type === 'draw')
|
||||
if (!shape) return null
|
||||
return (shape as any).props.segments.map((s: any) => ({
|
||||
dim: s.dim,
|
||||
pathLength: s.path.length,
|
||||
}))
|
||||
})
|
||||
|
||||
// The stroke produced a real draw shape with encoded points...
|
||||
expect(segments).not.toBeNull()
|
||||
expect(segments!.length).toBeGreaterThan(0)
|
||||
expect(segments!.every((s) => s.pathLength > 0)).toBe(true)
|
||||
// ...and the engine reported finger input as non-pressure, so every
|
||||
// segment took the 2D encoding. If this fails with dim undefined, the
|
||||
// browser has started reporting varying pressure for plain touches and
|
||||
// the classification in Drawing.ts needs revisiting.
|
||||
expect(segments!.every((s) => s.dim === 2)).toBe(true)
|
||||
})
|
||||
})
|
||||
49
apps/examples/e2e-device/tests/pinch.e2e.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { expect } from '@wdio/globals'
|
||||
import type { Editor } from 'tldraw'
|
||||
import { getSelectedShapeIds, getZoom, openEditor } from '../helpers/app'
|
||||
import { pinchCanvas } from '../helpers/pinch'
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
/**
|
||||
* Device-realistic pinch tests. These mirror the intent of the Playwright camera
|
||||
* tests (apps/examples/e2e/tests/test-camera.spec.ts) but run through the real
|
||||
* OS touch pipeline on a simulator/emulator, so they cover the browser's own
|
||||
* gesture recogniser — the part synthetic CDP touch can't reach.
|
||||
*/
|
||||
describe('pinch to zoom', () => {
|
||||
beforeEach(async () => {
|
||||
await openEditor()
|
||||
})
|
||||
|
||||
it('spreading two fingers zooms in', async () => {
|
||||
expect(await getZoom()).toBe(1)
|
||||
await pinchCanvas({ scale: 2.5 })
|
||||
expect(await getZoom()).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('pinching two fingers together zooms out', async () => {
|
||||
expect(await getZoom()).toBe(1)
|
||||
await pinchCanvas({ scale: 0.4 })
|
||||
expect(await getZoom()).toBeLessThan(1)
|
||||
})
|
||||
|
||||
it('a two-finger pinch does not change the selection', async () => {
|
||||
// Place a shape under where the pinch lands, plus a second shape that we
|
||||
// pre-select off to the side. The pinch must not steal the selection.
|
||||
await browser.execute(() => {
|
||||
editor.createShapes([
|
||||
{ id: 'shape:a', type: 'geo', x: -400, y: -400, props: { w: 100, h: 100 } },
|
||||
{ id: 'shape:b', type: 'geo', x: 0, y: 0, props: { w: 400, h: 400 } },
|
||||
] as any)
|
||||
editor.setSelectedShapes(['shape:a'] as any)
|
||||
})
|
||||
expect(await getSelectedShapeIds()).toEqual(['shape:a'])
|
||||
|
||||
await pinchCanvas({ scale: 2 })
|
||||
|
||||
// Selection is preserved and the gesture really did zoom.
|
||||
expect(await getSelectedShapeIds()).toEqual(['shape:a'])
|
||||
expect(await getZoom()).not.toBe(1)
|
||||
})
|
||||
})
|
||||
9
apps/examples/e2e-device/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../internal/config/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node", "@wdio/globals/types", "@wdio/mocha-framework"],
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["."]
|
||||
}
|
||||
22
apps/examples/e2e-device/wdio.android.conf.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Options } from '@wdio/types'
|
||||
import { shared } from './wdio.shared.conf'
|
||||
|
||||
/**
|
||||
* Drives real Chrome on the Android emulator via Appium's UiAutomator2 driver.
|
||||
* Multi-touch pinch is injected as real MotionEvents in the NATIVE context
|
||||
* (see helpers/pinch.ts) rather than through bare chromedriver, whose multi-touch
|
||||
* support is unreliable.
|
||||
*/
|
||||
export const config: Options.Testrunner = {
|
||||
...shared,
|
||||
capabilities: [
|
||||
{
|
||||
platformName: 'Android',
|
||||
'appium:automationName': 'UiAutomator2',
|
||||
browserName: 'Chrome',
|
||||
'appium:newCommandTimeout': 120,
|
||||
// Let Appium manage a matching chromedriver for the emulator's Chrome.
|
||||
'appium:chromedriverAutodownload': true,
|
||||
},
|
||||
],
|
||||
}
|
||||
48
apps/examples/e2e-device/wdio.ios.conf.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Options } from '@wdio/types'
|
||||
import { shared } from './wdio.shared.conf'
|
||||
|
||||
const deviceName = process.env.APPIUM_DEVICE_NAME ?? 'iPhone 16'
|
||||
const udid = process.env.APPIUM_UDID
|
||||
const derivedDataPath = process.env.APPIUM_DERIVED_DATA_PATH
|
||||
const usePrebuiltWDA = process.env.APPIUM_USE_PREBUILT_WDA === 'true'
|
||||
|
||||
/**
|
||||
* Drives real Mobile Safari in the iOS Simulator via Appium's XCUITest driver.
|
||||
* Touch gestures are performed in the NATIVE context (see helpers/pinch.ts),
|
||||
* which is the only place multi-touch W3C actions work on iOS — they go through
|
||||
* the real Simulator touch system and therefore through Safari's own gesture
|
||||
* recognizer.
|
||||
*/
|
||||
export const config: Options.Testrunner = {
|
||||
...shared,
|
||||
capabilities: [
|
||||
{
|
||||
platformName: 'iOS',
|
||||
'appium:automationName': 'XCUITest',
|
||||
'appium:deviceName': deviceName,
|
||||
// Attach to the simulator CI already booted (see the Boot step), so
|
||||
// XCUITest doesn't create/boot one itself.
|
||||
...(udid ? { 'appium:udid': udid } : {}),
|
||||
// Pin platformVersion in CI to the runtime shipped by the macos-26 image
|
||||
// if you need determinism; left unset here to use the booted default.
|
||||
browserName: 'Safari',
|
||||
'appium:safariInitialUrl': `${shared.baseUrl}/end-to-end`,
|
||||
'appium:newCommandTimeout': 120,
|
||||
// Let Appium own the simulator lifecycle (create/boot the one it uses).
|
||||
// Don't pre-boot in CI — a second simulator just causes boot contention.
|
||||
'appium:noReset': true,
|
||||
// First run builds WebDriverAgent from source, which is slow on a cold
|
||||
// CI simulator. Give the build/launch generous headroom (no signing is
|
||||
// needed on the simulator, so this is just time, not config).
|
||||
'appium:wdaLaunchTimeout': 300_000,
|
||||
'appium:wdaConnectionTimeout': 300_000,
|
||||
'appium:wdaStartupRetries': 2,
|
||||
'appium:wdaStartupRetryInterval': 20_000,
|
||||
'appium:showXcodeLog': false,
|
||||
// Build WDA into a stable, cacheable DerivedData dir; on a CI cache hit
|
||||
// reuse the prebuilt WDA and skip the xcodebuild step entirely.
|
||||
...(derivedDataPath ? { 'appium:derivedDataPath': derivedDataPath } : {}),
|
||||
...(usePrebuiltWDA ? { 'appium:usePrebuiltWDA': true } : {}),
|
||||
},
|
||||
],
|
||||
}
|
||||
58
apps/examples/e2e-device/wdio.shared.conf.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Options } from '@wdio/types'
|
||||
|
||||
const PORT = process.env.EXAMPLES_PORT ?? '5420'
|
||||
|
||||
/**
|
||||
* Base WebdriverIO config shared by the iOS and Android device suites. The
|
||||
* per-platform configs spread this and add their own `capabilities`.
|
||||
*
|
||||
* Appium is started for us by `@wdio/appium-service`; the relevant driver
|
||||
* (xcuitest / uiautomator2) is installed by the CI job before this runs.
|
||||
*/
|
||||
export const shared: Options.Testrunner = {
|
||||
runner: 'local',
|
||||
// Run the built examples app. iOS sim shares the host's localhost; the
|
||||
// Android job forwards the port into the emulator with `adb reverse`.
|
||||
baseUrl: `http://localhost:${PORT}`,
|
||||
|
||||
specs: ['./tests/**/*.e2e.ts'],
|
||||
maxInstances: 1,
|
||||
|
||||
logLevel: 'warn',
|
||||
waitforTimeout: 10_000,
|
||||
// Generous per-request timeout: the first session creation has to build and
|
||||
// launch WebDriverAgent on a cold simulator, which can take several minutes.
|
||||
connectionRetryTimeout: 600_000,
|
||||
// Allow exactly one session-creation retry (1 retry = two attempts). On iOS
|
||||
// the first attempt can fail on Safari's slow web-inspector attach; the retry
|
||||
// reuses the already-booted simulator and running WebDriverAgent (CI attaches
|
||||
// by udid), so it recovers quickly. Don't raise this: when Appium owned the
|
||||
// simulator lifecycle, stacked retries booted multiple simulators at once and
|
||||
// they all timed out.
|
||||
connectionRetryCount: 1,
|
||||
|
||||
framework: 'mocha',
|
||||
mochaOpts: {
|
||||
ui: 'bdd',
|
||||
timeout: 90_000,
|
||||
// Gestures are inherently a little flaky; let an individual test retry
|
||||
// before failing the run. Re-running the whole job is always an option too.
|
||||
retries: 1,
|
||||
},
|
||||
// Retry an entire spec file once if it fails outright (e.g. a flaky sim boot).
|
||||
specFileRetries: 1,
|
||||
specFileRetriesDeferred: true,
|
||||
|
||||
reporters: ['spec'],
|
||||
|
||||
services: [
|
||||
[
|
||||
'appium',
|
||||
{
|
||||
args: { relaxedSecurity: true },
|
||||
// Appium is installed globally in CI; use that binary.
|
||||
command: 'appium',
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
146
apps/examples/e2e/baselines/fps-baselines.json
Normal file
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"baselines": {
|
||||
"linux-1280x720": {
|
||||
"rotate_shapes": {
|
||||
"avgFps": 25,
|
||||
"minFps": 8,
|
||||
"maxFps": 58,
|
||||
"timestamp": "2025-12-06T22:18:16.000Z",
|
||||
"environment": {
|
||||
"platform": "linux",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
},
|
||||
"drag_shapes": {
|
||||
"avgFps": 54,
|
||||
"minFps": 26,
|
||||
"maxFps": 69,
|
||||
"timestamp": "2025-07-21T14:28:04.232Z",
|
||||
"environment": {
|
||||
"platform": "linux",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
},
|
||||
"resize_shapes": {
|
||||
"avgFps": 22,
|
||||
"minFps": 18,
|
||||
"maxFps": 67,
|
||||
"timestamp": "2025-07-21T14:28:23.924Z",
|
||||
"environment": {
|
||||
"platform": "linux",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
},
|
||||
"canvas_panning": {
|
||||
"avgFps": 38,
|
||||
"minFps": 28,
|
||||
"maxFps": 70,
|
||||
"timestamp": "2025-07-21T14:28:13.302Z",
|
||||
"environment": {
|
||||
"platform": "linux",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
},
|
||||
"canvas_zooming": {
|
||||
"avgFps": 41,
|
||||
"minFps": 19,
|
||||
"maxFps": 67,
|
||||
"timestamp": "2025-07-21T14:28:23.924Z",
|
||||
"environment": {
|
||||
"platform": "linux",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
}
|
||||
},
|
||||
"darwin-1280x720": {
|
||||
"rotate_shapes": {
|
||||
"avgFps": 60,
|
||||
"minFps": 47,
|
||||
"maxFps": 65,
|
||||
"timestamp": "2025-11-26T22:53:39.487Z",
|
||||
"environment": {
|
||||
"platform": "darwin",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
},
|
||||
"drag_shapes": {
|
||||
"avgFps": 60,
|
||||
"minFps": 48,
|
||||
"maxFps": 67,
|
||||
"timestamp": "2025-11-26T22:53:50.324Z",
|
||||
"environment": {
|
||||
"platform": "darwin",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
},
|
||||
"resize_shapes": {
|
||||
"avgFps": 58,
|
||||
"minFps": 49,
|
||||
"maxFps": 71,
|
||||
"timestamp": "2025-11-26T22:54:01.975Z",
|
||||
"environment": {
|
||||
"platform": "darwin",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
},
|
||||
"canvas_panning": {
|
||||
"avgFps": 60,
|
||||
"minFps": 49,
|
||||
"maxFps": 70,
|
||||
"timestamp": "2025-11-26T22:54:09.322Z",
|
||||
"environment": {
|
||||
"platform": "darwin",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
},
|
||||
"canvas_zooming": {
|
||||
"avgFps": 59,
|
||||
"minFps": 35,
|
||||
"maxFps": 63,
|
||||
"timestamp": "2025-11-26T22:54:23.386Z",
|
||||
"environment": {
|
||||
"platform": "darwin",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
},
|
||||
"create_text_shapes": {
|
||||
"avgFps": 117,
|
||||
"minFps": 72,
|
||||
"maxFps": 137,
|
||||
"timestamp": "2026-06-22T09:19:08.333Z",
|
||||
"environment": {
|
||||
"platform": "darwin",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
}
|
||||
},
|
||||
"darwin-393x727": {
|
||||
"drag_shapes": {
|
||||
"avgFps": 60,
|
||||
"minFps": 58,
|
||||
"maxFps": 69,
|
||||
"timestamp": "2025-11-26T22:54:24.557Z",
|
||||
"environment": {
|
||||
"platform": "darwin",
|
||||
"viewport": "393x727",
|
||||
"browser": "chromium"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"lastUpdated": "2026-06-22T09:19:08.334Z",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
178
apps/examples/e2e/fixtures/analytics-reporter.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { PerformanceTestResult } from './perf-utils'
|
||||
|
||||
export interface AnalyticsConfig {
|
||||
enabled: boolean
|
||||
projectKey?: string
|
||||
apiHost?: string
|
||||
}
|
||||
|
||||
export class PerformanceAnalyticsReporter {
|
||||
private config: AnalyticsConfig
|
||||
|
||||
constructor(config: AnalyticsConfig = { enabled: false }) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
async reportPerformanceMetrics(
|
||||
results: PerformanceTestResult[],
|
||||
testContext: {
|
||||
testName: string
|
||||
timestamp: string
|
||||
gitCommit?: string
|
||||
branch?: string
|
||||
ciEnvironment?: string
|
||||
}
|
||||
): Promise<void> {
|
||||
if (!this.config.enabled) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
'Analytics reporting disabled - would have sent:',
|
||||
results.length,
|
||||
'performance results'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = this.formatPayload(results, testContext)
|
||||
// See https://eu.posthog.com/project/45972/insights/ZdpGyj1r
|
||||
await this.sendToPostHog(payload)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Sent ${results.length} performance metrics to PostHog`)
|
||||
} catch (error) {
|
||||
console.error('Failed to send performance metrics:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private formatPayload(results: PerformanceTestResult[], testContext: any) {
|
||||
return results.map((result) => ({
|
||||
event: 'performance_test_result',
|
||||
properties: {
|
||||
// Test identification
|
||||
test_name: testContext.testName,
|
||||
interaction: result.interaction,
|
||||
timestamp: testContext.timestamp,
|
||||
|
||||
// Environment
|
||||
platform: result.environment.platform,
|
||||
viewport: result.environment.viewport,
|
||||
browser: result.environment.browser,
|
||||
|
||||
// Performance metrics
|
||||
avg_fps: result.metrics.averageFps,
|
||||
min_fps: result.metrics.minFps,
|
||||
max_fps: result.metrics.maxFps,
|
||||
total_frames: result.metrics.totalFrames,
|
||||
duration_seconds: result.metrics.duration,
|
||||
sample_count: result.metrics.samples.length,
|
||||
|
||||
// Comparison results
|
||||
baseline_avg_fps: result.comparison.baseline.avgFps,
|
||||
fps_change_percent: result.comparison.avgFpsChange,
|
||||
is_regression: result.comparison.isRegression,
|
||||
is_improvement: result.comparison.isImprovement,
|
||||
status: result.comparison.status,
|
||||
|
||||
// CI/Git context
|
||||
git_commit: testContext.gitCommit,
|
||||
git_branch: testContext.branch,
|
||||
ci_environment: testContext.ciEnvironment,
|
||||
|
||||
// Additional metadata
|
||||
fps_samples: result.metrics.samples.slice(0, 10), // First 10 samples for analysis
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
private async sendToPostHog(events: any[]): Promise<void> {
|
||||
if (!this.config.projectKey) {
|
||||
throw new Error('PostHog project key not configured')
|
||||
}
|
||||
|
||||
const url = `${this.config.apiHost || 'https://analytics.tldraw.com'}/capture/`
|
||||
|
||||
for (const event of events) {
|
||||
const payload = {
|
||||
api_key: this.config.projectKey,
|
||||
event: event.event,
|
||||
properties: {
|
||||
...event.properties,
|
||||
distinct_id: `e2e-test-${Date.now()}`, // Anonymous test identifier
|
||||
$lib: 'tldraw-e2e-perf',
|
||||
$lib_version: '1.0.0',
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`PostHog API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async reportRegressionAlert(result: PerformanceTestResult, testContext: any): Promise<void> {
|
||||
if (!this.config.enabled || result.comparison.status !== 'fail') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const alertPayload = {
|
||||
event: 'performance_regression_alert',
|
||||
properties: {
|
||||
test_name: testContext.testName,
|
||||
interaction: result.interaction,
|
||||
avg_fps_drop: Math.abs(result.comparison.avgFpsChange),
|
||||
current_fps: result.metrics.averageFps,
|
||||
baseline_fps: result.comparison.baseline.avgFps,
|
||||
platform: result.environment.platform,
|
||||
browser: result.environment.browser,
|
||||
timestamp: testContext.timestamp,
|
||||
git_commit: testContext.gitCommit,
|
||||
git_branch: testContext.branch,
|
||||
alert_message: result.comparison.message,
|
||||
severity: result.comparison.avgFpsChange < -20 ? 'high' : 'medium',
|
||||
},
|
||||
}
|
||||
|
||||
await this.sendToPostHog([alertPayload])
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Sent performance regression alert for ${result.interaction}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to send regression alert:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function to create analytics reporter with environment-based config
|
||||
export function createAnalyticsReporter(): PerformanceAnalyticsReporter {
|
||||
const config: AnalyticsConfig = {
|
||||
enabled: process.env.PERFORMANCE_ANALYTICS_ENABLED === 'true',
|
||||
projectKey: 'phc_i8oKgMzgV38sn3GfjswW9mevQ3gFlo7bJXekZFeDN6',
|
||||
apiHost: process.env.POSTHOG_API_HOST || 'https://analytics.tldraw.com/ingest',
|
||||
}
|
||||
|
||||
return new PerformanceAnalyticsReporter(config)
|
||||
}
|
||||
|
||||
// Helper to collect test context from environment
|
||||
export function getTestContext(testName: string) {
|
||||
return {
|
||||
testName,
|
||||
timestamp: new Date().toISOString(),
|
||||
gitCommit: process.env.GIT_COMMIT || process.env.GITHUB_SHA,
|
||||
branch: process.env.GIT_BRANCH || process.env.GITHUB_REF_NAME,
|
||||
ciEnvironment: process.env.CI
|
||||
? process.env.GITHUB_ACTIONS
|
||||
? 'github-actions'
|
||||
: 'ci'
|
||||
: 'local',
|
||||
}
|
||||
}
|
||||
261
apps/examples/e2e/fixtures/baseline-manager.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { FPSMetrics } from './fps-tracker'
|
||||
|
||||
export interface PerformanceBaseline {
|
||||
avgFps: number
|
||||
minFps: number
|
||||
maxFps: number
|
||||
timestamp: string
|
||||
environment: {
|
||||
platform: string
|
||||
viewport: string
|
||||
browser: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface BaselineData {
|
||||
baselines: {
|
||||
[platform: string]: {
|
||||
[interaction: string]: PerformanceBaseline
|
||||
}
|
||||
}
|
||||
metadata: {
|
||||
lastUpdated: string
|
||||
version: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ComparisonResult {
|
||||
interaction: string
|
||||
current: FPSMetrics
|
||||
baseline: PerformanceBaseline
|
||||
avgFpsChange: number // Percentage change
|
||||
minFpsChange: number
|
||||
isRegression: boolean
|
||||
isImprovement: boolean
|
||||
status: 'pass' | 'warning' | 'fail'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface Environment {
|
||||
platform: string
|
||||
viewport: string
|
||||
browser: string
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname)
|
||||
|
||||
export class BaselineManager {
|
||||
private baselineFilePath: string
|
||||
private regressionThreshold = 15 // Percentage
|
||||
private warningThreshold = 10 // Percentage
|
||||
|
||||
constructor(baselineDir = path.join(__dirname, '..', 'baselines')) {
|
||||
// Ensure baseline directory exists
|
||||
if (!fs.existsSync(baselineDir)) {
|
||||
fs.mkdirSync(baselineDir, { recursive: true })
|
||||
}
|
||||
this.baselineFilePath = path.join(baselineDir, 'fps-baselines.json')
|
||||
}
|
||||
|
||||
private loadBaselines(): BaselineData {
|
||||
if (!fs.existsSync(this.baselineFilePath)) {
|
||||
return {
|
||||
baselines: {},
|
||||
metadata: {
|
||||
lastUpdated: new Date().toISOString(),
|
||||
version: '1.0.0',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(this.baselineFilePath, 'utf-8')
|
||||
return JSON.parse(content)
|
||||
} catch (error) {
|
||||
console.warn('Failed to load baseline file, creating new one:', error)
|
||||
return {
|
||||
baselines: {},
|
||||
metadata: {
|
||||
lastUpdated: new Date().toISOString(),
|
||||
version: '1.0.0',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private saveBaselines(data: BaselineData): void {
|
||||
try {
|
||||
const content = JSON.stringify(data, null, 2)
|
||||
fs.writeFileSync(this.baselineFilePath, content, 'utf-8')
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to save baselines: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
private getPlatformKey(env: Environment): string {
|
||||
return `${env.platform}-${env.viewport}`
|
||||
}
|
||||
|
||||
private calculatePercentageChange(current: number, baseline: number): number {
|
||||
if (baseline === 0) return 0
|
||||
return ((current - baseline) / baseline) * 100
|
||||
}
|
||||
|
||||
updateBaseline(
|
||||
interaction: string,
|
||||
metrics: FPSMetrics,
|
||||
environment: Environment,
|
||||
force = false
|
||||
): void {
|
||||
const data = this.loadBaselines()
|
||||
const platformKey = this.getPlatformKey(environment)
|
||||
|
||||
if (!data.baselines[platformKey]) {
|
||||
data.baselines[platformKey] = {}
|
||||
}
|
||||
|
||||
const existingBaseline = data.baselines[platformKey][interaction]
|
||||
const newBaseline: PerformanceBaseline = {
|
||||
avgFps: metrics.averageFps,
|
||||
minFps: metrics.minFps,
|
||||
maxFps: metrics.maxFps,
|
||||
timestamp: new Date().toISOString(),
|
||||
environment,
|
||||
}
|
||||
|
||||
// Only update if it's an improvement, forced, or no existing baseline
|
||||
if (!existingBaseline || force || metrics.averageFps > existingBaseline.avgFps) {
|
||||
data.baselines[platformKey][interaction] = newBaseline
|
||||
data.metadata.lastUpdated = new Date().toISOString()
|
||||
this.saveBaselines(data)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Updated baseline for ${interaction} on ${platformKey}:`, newBaseline)
|
||||
}
|
||||
}
|
||||
|
||||
compareWithBaseline(
|
||||
interaction: string,
|
||||
metrics: FPSMetrics,
|
||||
environment: Environment,
|
||||
autoCreateBaseline = true
|
||||
): ComparisonResult {
|
||||
const data = this.loadBaselines()
|
||||
const platformKey = this.getPlatformKey(environment)
|
||||
const baseline = data.baselines[platformKey]?.[interaction]
|
||||
|
||||
if (!baseline) {
|
||||
if (autoCreateBaseline) {
|
||||
// Automatically create baseline on first run
|
||||
this.updateBaseline(interaction, metrics, environment, true)
|
||||
return {
|
||||
interaction,
|
||||
current: metrics,
|
||||
baseline: {
|
||||
avgFps: metrics.averageFps,
|
||||
minFps: metrics.minFps,
|
||||
maxFps: metrics.maxFps,
|
||||
timestamp: new Date().toISOString(),
|
||||
environment,
|
||||
},
|
||||
avgFpsChange: 0,
|
||||
minFpsChange: 0,
|
||||
isRegression: false,
|
||||
isImprovement: false,
|
||||
status: 'pass',
|
||||
message: `New baseline created for ${interaction} on ${platformKey}. Average FPS: ${metrics.averageFps}`,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
interaction,
|
||||
current: metrics,
|
||||
baseline: {
|
||||
avgFps: 0,
|
||||
minFps: 0,
|
||||
maxFps: 0,
|
||||
timestamp: '',
|
||||
environment,
|
||||
},
|
||||
avgFpsChange: 0,
|
||||
minFpsChange: 0,
|
||||
isRegression: false,
|
||||
isImprovement: false,
|
||||
status: 'warning',
|
||||
message: `No baseline found for ${interaction} on ${platformKey}. Consider setting a baseline first.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const avgFpsChange = this.calculatePercentageChange(metrics.averageFps, baseline.avgFps)
|
||||
const minFpsChange = this.calculatePercentageChange(metrics.minFps, baseline.minFps)
|
||||
|
||||
const isRegression = avgFpsChange < -this.regressionThreshold
|
||||
const isImprovement = avgFpsChange > this.warningThreshold
|
||||
|
||||
let status: 'pass' | 'warning' | 'fail' = 'pass'
|
||||
let message = `Performance stable. Average FPS: ${metrics.averageFps} (${avgFpsChange.toFixed(1)}% change)`
|
||||
|
||||
if (isRegression) {
|
||||
status = 'fail'
|
||||
message = `Performance regression detected! Average FPS dropped by ${Math.abs(avgFpsChange).toFixed(1)}% (${baseline.avgFps} → ${metrics.averageFps})`
|
||||
} else if (isImprovement) {
|
||||
status = 'warning' // Warning to indicate a significant change that might warrant baseline update
|
||||
message = `Performance improvement detected! Average FPS improved by ${avgFpsChange.toFixed(1)}% (${baseline.avgFps} → ${metrics.averageFps}). Consider updating baseline.`
|
||||
}
|
||||
|
||||
return {
|
||||
interaction,
|
||||
current: metrics,
|
||||
baseline,
|
||||
avgFpsChange,
|
||||
minFpsChange,
|
||||
isRegression,
|
||||
isImprovement,
|
||||
status,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
getAllBaselines(): BaselineData {
|
||||
return this.loadBaselines()
|
||||
}
|
||||
|
||||
getBaseline(interaction: string, environment: Environment): PerformanceBaseline | null {
|
||||
const data = this.loadBaselines()
|
||||
const platformKey = this.getPlatformKey(environment)
|
||||
return data.baselines[platformKey]?.[interaction] || null
|
||||
}
|
||||
|
||||
deleteBaseline(interaction: string, environment: Environment): boolean {
|
||||
const data = this.loadBaselines()
|
||||
const platformKey = this.getPlatformKey(environment)
|
||||
|
||||
if (data.baselines[platformKey]?.[interaction]) {
|
||||
delete data.baselines[platformKey][interaction]
|
||||
data.metadata.lastUpdated = new Date().toISOString()
|
||||
this.saveBaselines(data)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
setThresholds(regressionThreshold: number, warningThreshold: number): void {
|
||||
if (regressionThreshold <= 0 || warningThreshold <= 0) {
|
||||
throw new Error('Thresholds must be positive numbers')
|
||||
}
|
||||
if (warningThreshold > regressionThreshold) {
|
||||
throw new Error('Warning threshold must be less than or equal to regression threshold')
|
||||
}
|
||||
|
||||
this.regressionThreshold = regressionThreshold
|
||||
this.warningThreshold = warningThreshold
|
||||
}
|
||||
|
||||
getThresholds(): { regressionThreshold: number; warningThreshold: number } {
|
||||
return {
|
||||
regressionThreshold: this.regressionThreshold,
|
||||
warningThreshold: this.warningThreshold,
|
||||
}
|
||||
}
|
||||
}
|
||||
110
apps/examples/e2e/fixtures/fixtures.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Page, test as base } from '@playwright/test'
|
||||
import { EndToEndApi } from '../../src/misc/EndToEndApi'
|
||||
import { ActionsMenu } from './menus/ActionsMenu'
|
||||
import { HelpMenu } from './menus/HelpMenu'
|
||||
import { MainMenu } from './menus/MainMenu'
|
||||
import { MenuClickCapture } from './menus/MenuClickCapture'
|
||||
import { NavigationPanel } from './menus/NavigationPanel'
|
||||
import { PageMenu } from './menus/PageMenu'
|
||||
import { RichTextToolbar } from './menus/RichTextToolbar'
|
||||
import { StylePanel } from './menus/StylePanel'
|
||||
import { Toolbar } from './menus/Toolbar'
|
||||
|
||||
interface Fixtures {
|
||||
menuClickCapture: MenuClickCapture
|
||||
toolbar: Toolbar
|
||||
richTextToolbar: RichTextToolbar
|
||||
stylePanel: StylePanel
|
||||
actionsMenu: ActionsMenu
|
||||
helpMenu: HelpMenu
|
||||
mainMenu: MainMenu
|
||||
pageMenu: PageMenu
|
||||
navigationPanel: NavigationPanel
|
||||
api: ReturnType<typeof makeApiFixture>
|
||||
isMac: boolean
|
||||
}
|
||||
|
||||
export type ApiFixture = {
|
||||
[K in keyof EndToEndApi]: (
|
||||
...args: Parameters<EndToEndApi[K]>
|
||||
) => Promise<ReturnType<EndToEndApi[K]>>
|
||||
}
|
||||
|
||||
function makeApiFixture(keys: { [K in keyof EndToEndApi]: true }, page: Page): ApiFixture {
|
||||
const result = {} as any
|
||||
|
||||
for (const key of Object.keys(keys)) {
|
||||
result[key] = (...args: any[]) => {
|
||||
return page.evaluate(
|
||||
([key, ...args]) => {
|
||||
return (window as any).tldrawApi[key](...args)
|
||||
},
|
||||
[key, ...args]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const test = base.extend<Fixtures>({
|
||||
menuClickCapture: async ({ page }, testUse) => {
|
||||
const toolbar = new MenuClickCapture(page)
|
||||
await testUse(toolbar)
|
||||
},
|
||||
toolbar: async ({ page }, testUse) => {
|
||||
const toolbar = new Toolbar(page)
|
||||
await testUse(toolbar)
|
||||
},
|
||||
richTextToolbar: async ({ page }, testUse) => {
|
||||
const richTextToolbar = new RichTextToolbar(page)
|
||||
await testUse(richTextToolbar)
|
||||
},
|
||||
stylePanel: async ({ page }, testUse) => {
|
||||
const stylePanel = new StylePanel(page)
|
||||
await testUse(stylePanel)
|
||||
},
|
||||
actionsMenu: async ({ page }, testUse) => {
|
||||
const actionsMenu = new ActionsMenu(page)
|
||||
await testUse(actionsMenu)
|
||||
},
|
||||
helpMenu: async ({ page }, testUse) => {
|
||||
const helpMenu = new HelpMenu(page)
|
||||
await testUse(helpMenu)
|
||||
},
|
||||
mainMenu: async ({ page }, testUse) => {
|
||||
const mainMenu = new MainMenu(page)
|
||||
await testUse(mainMenu)
|
||||
},
|
||||
pageMenu: async ({ page }, testUse) => {
|
||||
const pagemenu = new PageMenu(page)
|
||||
await testUse(pagemenu)
|
||||
},
|
||||
navigationPanel: async ({ page }, testUse) => {
|
||||
const navigationPanel = new NavigationPanel(page)
|
||||
await testUse(navigationPanel)
|
||||
},
|
||||
api: async ({ page }, testUse) => {
|
||||
const api = makeApiFixture(
|
||||
{
|
||||
exportAsSvg: true,
|
||||
exportAsFormat: true,
|
||||
createShapeId: true,
|
||||
resetMockShapeIds: true,
|
||||
createMermaidDiagram: true,
|
||||
checkMermaidReadability: true,
|
||||
toRichText: true,
|
||||
preloadFonts: true,
|
||||
markAllArrowBindings: true,
|
||||
b64VecsEncodePoints: true,
|
||||
},
|
||||
page
|
||||
)
|
||||
await testUse(api)
|
||||
},
|
||||
isMac: async ({ page: _ }, testUse) => {
|
||||
await testUse(process.platform === 'darwin')
|
||||
},
|
||||
})
|
||||
|
||||
export default test
|
||||
155
apps/examples/e2e/fixtures/fps-tracker.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
export interface FPSMetrics {
|
||||
averageFps: number
|
||||
minFps: number
|
||||
maxFps: number
|
||||
totalFrames: number
|
||||
duration: number
|
||||
samples: number[]
|
||||
}
|
||||
|
||||
export class E2EFpsTracker {
|
||||
private isTracking = false
|
||||
private startTime = 0
|
||||
private frameCount = 0
|
||||
private fpsSamples: number[] = []
|
||||
private rafId: number | null = null
|
||||
private sampleInterval = 100 // Sample FPS every 100ms
|
||||
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.isTracking) {
|
||||
throw new Error('FPS tracking is already running')
|
||||
}
|
||||
|
||||
this.isTracking = true
|
||||
this.startTime = 0
|
||||
this.frameCount = 0
|
||||
this.fpsSamples = []
|
||||
this.rafId = null
|
||||
|
||||
// Inject FPS tracking script into the page
|
||||
await this.page.evaluate(
|
||||
({ sampleInterval }) => {
|
||||
;(window as any).__e2eFpsTracker = {
|
||||
isTracking: true,
|
||||
startTime: performance.now(),
|
||||
frameCount: 0,
|
||||
fpsSamples: [],
|
||||
lastSampleTime: performance.now(),
|
||||
sampleInterval,
|
||||
|
||||
trackFrame: function () {
|
||||
if (!this.isTracking) return
|
||||
|
||||
this.frameCount++
|
||||
const now = performance.now()
|
||||
|
||||
// Sample FPS at regular intervals
|
||||
if (now - this.lastSampleTime >= this.sampleInterval) {
|
||||
const duration = (now - this.lastSampleTime) / 1000
|
||||
const framesInPeriod = this.frameCount - (this.lastFrameCount || 0)
|
||||
const currentFps = duration > 0 ? framesInPeriod / duration : 0
|
||||
|
||||
this.fpsSamples.push(Math.round(currentFps))
|
||||
this.lastSampleTime = now
|
||||
this.lastFrameCount = this.frameCount
|
||||
}
|
||||
|
||||
// Continue tracking
|
||||
this.rafId = requestAnimationFrame(() => this.trackFrame())
|
||||
},
|
||||
}
|
||||
|
||||
// Start the tracking loop
|
||||
const tracker = (window as any).__e2eFpsTracker
|
||||
tracker.rafId = requestAnimationFrame(() => tracker.trackFrame())
|
||||
},
|
||||
{ sampleInterval: this.sampleInterval }
|
||||
)
|
||||
}
|
||||
|
||||
async stop(): Promise<FPSMetrics> {
|
||||
if (!this.isTracking) {
|
||||
throw new Error('FPS tracking is not running')
|
||||
}
|
||||
|
||||
// Stop tracking and get final measurements
|
||||
const metrics = await this.page.evaluate(() => {
|
||||
const tracker = (window as any).__e2eFpsTracker
|
||||
if (!tracker) {
|
||||
throw new Error('FPS tracker not found on page')
|
||||
}
|
||||
|
||||
tracker.isTracking = false
|
||||
if (tracker.rafId) {
|
||||
cancelAnimationFrame(tracker.rafId)
|
||||
}
|
||||
|
||||
const totalDuration = (performance.now() - tracker.startTime) / 1000
|
||||
const samples = tracker.fpsSamples.filter((fps: number) => fps > 0) // Filter out invalid samples
|
||||
|
||||
if (samples.length === 0) {
|
||||
return {
|
||||
averageFps: 0,
|
||||
minFps: 0,
|
||||
maxFps: 0,
|
||||
totalFrames: tracker.frameCount,
|
||||
duration: totalDuration,
|
||||
samples: [],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
averageFps: Math.round(samples.reduce((a: number, b: number) => a + b, 0) / samples.length),
|
||||
minFps: Math.min(...samples),
|
||||
maxFps: Math.max(...samples),
|
||||
totalFrames: tracker.frameCount,
|
||||
duration: totalDuration,
|
||||
samples: samples,
|
||||
}
|
||||
})
|
||||
|
||||
this.isTracking = false
|
||||
|
||||
// Clean up the tracker from the page
|
||||
await this.page.evaluate(() => {
|
||||
delete (window as any).__e2eFpsTracker
|
||||
})
|
||||
|
||||
return metrics
|
||||
}
|
||||
|
||||
async measureInteraction<T>(
|
||||
interactionFn: () => Promise<T>,
|
||||
options: { warmupMs?: number; measureMs?: number } = {}
|
||||
): Promise<{ result: T; metrics: FPSMetrics }> {
|
||||
const { warmupMs = 500, measureMs = 3000 } = options
|
||||
|
||||
// Warm up period - let the browser settle
|
||||
if (warmupMs > 0) {
|
||||
await this.page.waitForTimeout(warmupMs)
|
||||
}
|
||||
|
||||
await this.start()
|
||||
|
||||
// Execute the interaction
|
||||
const result = await interactionFn()
|
||||
|
||||
// Measure for the specified duration
|
||||
await this.page.waitForTimeout(measureMs)
|
||||
|
||||
const metrics = await this.stop()
|
||||
|
||||
return { result, metrics }
|
||||
}
|
||||
|
||||
async isTrackingActive(): Promise<boolean> {
|
||||
return this.page.evaluate(() => {
|
||||
const tracker = (window as any).__e2eFpsTracker
|
||||
return tracker ? tracker.isTracking : false
|
||||
})
|
||||
}
|
||||
}
|
||||
304
apps/examples/e2e/fixtures/heavy-board-generator.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
export interface HeavyBoardOptions {
|
||||
shapeCount?: number
|
||||
includeGroups?: boolean
|
||||
includeText?: boolean
|
||||
includeArrows?: boolean
|
||||
canvasWidth?: number
|
||||
canvasHeight?: number
|
||||
seed?: number // For reproducible layouts
|
||||
}
|
||||
|
||||
export class HeavyBoardGenerator {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async generateHeavyBoard(options: HeavyBoardOptions = {}): Promise<void> {
|
||||
const {
|
||||
shapeCount = 300,
|
||||
includeGroups = true,
|
||||
includeText = true,
|
||||
includeArrows = true,
|
||||
canvasWidth = 2000,
|
||||
canvasHeight = 1500,
|
||||
seed = 12345,
|
||||
} = options
|
||||
|
||||
// Clear existing shapes first
|
||||
await this.page.keyboard.press('Control+a')
|
||||
await this.page.keyboard.press('Backspace')
|
||||
|
||||
// Wait for canvas to be ready
|
||||
await this.page.waitForTimeout(100)
|
||||
|
||||
// Generate shapes using editor API injection
|
||||
await this.page.evaluate(
|
||||
({
|
||||
shapeCount,
|
||||
includeGroups: _includeGroups,
|
||||
includeText,
|
||||
includeArrows,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
seed,
|
||||
}) => {
|
||||
const editor = (window as any).editor
|
||||
if (!editor) {
|
||||
throw new Error('Editor not found on window')
|
||||
}
|
||||
|
||||
// Simple seeded random number generator for reproducible tests
|
||||
class SeededRandom {
|
||||
private seed: number
|
||||
constructor(seed: number) {
|
||||
this.seed = seed
|
||||
}
|
||||
|
||||
next(): number {
|
||||
this.seed = (this.seed * 9301 + 49297) % 233280
|
||||
return this.seed / 233280
|
||||
}
|
||||
|
||||
range(min: number, max: number): number {
|
||||
return Math.floor(this.next() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
choice<T>(array: T[]): T {
|
||||
return array[this.range(0, array.length - 1)]
|
||||
}
|
||||
}
|
||||
|
||||
const random = new SeededRandom(seed)
|
||||
const shapes: any[] = []
|
||||
|
||||
// Shape types and their weights
|
||||
const shapeTypes = [
|
||||
{ type: 'geo', weight: 50 }, // rectangles, ellipses, etc.
|
||||
{ type: 'text', weight: includeText ? 25 : 0 },
|
||||
{ type: 'arrow', weight: includeArrows ? 25 : 0 },
|
||||
{ type: 'note', weight: 25 },
|
||||
{ type: 'draw', weight: 25 },
|
||||
].filter((s) => s.weight > 0)
|
||||
|
||||
// Generate base shapes
|
||||
for (let i = 0; i < shapeCount; i++) {
|
||||
const x = random.range(0, canvasWidth)
|
||||
const y = random.range(0, canvasHeight)
|
||||
const width = random.range(50, 200)
|
||||
const height = random.range(30, 150)
|
||||
|
||||
// Pick shape type based on weights
|
||||
let weightSum = 0
|
||||
const targetWeight = random.next() * shapeTypes.reduce((sum, s) => sum + s.weight, 0)
|
||||
const shapeType =
|
||||
shapeTypes.find((s) => (weightSum += s.weight) >= targetWeight)?.type || 'geo'
|
||||
|
||||
let shape: any
|
||||
|
||||
switch (shapeType) {
|
||||
case 'geo': {
|
||||
const geoTypes = ['rectangle', 'ellipse', 'triangle', 'diamond', 'hexagon', 'star']
|
||||
shape = {
|
||||
id: `shape:${i}`,
|
||||
type: 'geo',
|
||||
x,
|
||||
y,
|
||||
props: {
|
||||
w: width,
|
||||
h: height,
|
||||
geo: random.choice(geoTypes),
|
||||
color: random.choice([
|
||||
'black',
|
||||
'blue',
|
||||
'green',
|
||||
'red',
|
||||
'yellow',
|
||||
'orange',
|
||||
'violet',
|
||||
]),
|
||||
fill: random.choice(['none', 'semi', 'solid']),
|
||||
size: random.choice(['s', 'm', 'l', 'xl']),
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'draw': {
|
||||
// Create a simple drawn shape with some points
|
||||
const numPoints = random.range(3, 8)
|
||||
const points: Array<{ x: number; y: number; z: number }> = []
|
||||
for (let p = 0; p < numPoints; p++) {
|
||||
points.push({
|
||||
x: random.range(0, width),
|
||||
y: random.range(0, height),
|
||||
z: 0.5,
|
||||
})
|
||||
}
|
||||
|
||||
// Access b64Vecs from the window.tldrawApi object
|
||||
const tldrawApi = (window as any).tldrawApi
|
||||
const b64VecsEncodePoints = tldrawApi.b64VecsEncodePoints
|
||||
|
||||
shape = {
|
||||
id: `shape:${i}`,
|
||||
type: 'draw',
|
||||
x,
|
||||
y,
|
||||
props: {
|
||||
segments: [
|
||||
{
|
||||
type: 'free',
|
||||
path: b64VecsEncodePoints(points),
|
||||
},
|
||||
],
|
||||
color: random.choice(['black', 'blue', 'green', 'red']),
|
||||
size: random.choice(['s', 'm', 'l']),
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'text': {
|
||||
const tldrawApi = (window as any).tldrawApi
|
||||
const textSamples = [
|
||||
'Performance Test',
|
||||
'Lorem ipsum dolor sit amet',
|
||||
'Heavy Board Test',
|
||||
'Sample Text ' + i,
|
||||
'Multi-line\nText Content',
|
||||
'Longer text content for testing purposes',
|
||||
]
|
||||
shape = {
|
||||
id: `shape:${i}`,
|
||||
type: 'text',
|
||||
x,
|
||||
y,
|
||||
props: {
|
||||
richText: tldrawApi.toRichText(random.choice(textSamples)),
|
||||
color: random.choice(['black', 'blue', 'green', 'red']),
|
||||
size: random.choice(['s', 'm', 'l', 'xl']),
|
||||
font: 'draw',
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'arrow': {
|
||||
const endX = x + random.range(-200, 200)
|
||||
const endY = y + random.range(-200, 200)
|
||||
shape = {
|
||||
id: `shape:${i}`,
|
||||
type: 'arrow',
|
||||
x,
|
||||
y,
|
||||
props: {
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: endX - x, y: endY - y },
|
||||
color: random.choice(['black', 'blue', 'green', 'red']),
|
||||
size: random.choice(['s', 'm', 'l']),
|
||||
arrowheadStart: random.choice(['none', 'arrow', 'triangle']),
|
||||
arrowheadEnd: random.choice(['arrow', 'triangle', 'dot']),
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'note': {
|
||||
const tldrawApi = (window as any).tldrawApi
|
||||
shape = {
|
||||
id: `shape:${i}`,
|
||||
type: 'note',
|
||||
x,
|
||||
y,
|
||||
props: {
|
||||
richText: tldrawApi.toRichText(`Note ${i}\nPerformance testing note`),
|
||||
color: random.choice(['black', 'blue', 'green', 'red', 'yellow']),
|
||||
size: random.choice(['s', 'm', 'l']),
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (shape) {
|
||||
shapes.push(shape)
|
||||
}
|
||||
}
|
||||
|
||||
// Create all shapes at once
|
||||
editor.createShapes(shapes)
|
||||
|
||||
// Skip grouping for now - will add back later when debugging shape creation issues
|
||||
// TODO: Re-enable grouping once basic shape generation is working
|
||||
|
||||
// Zoom to fit all content
|
||||
editor.zoomToFit()
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Generated heavy board with ${shapes.length} shapes`)
|
||||
},
|
||||
{ shapeCount, includeGroups, includeText, includeArrows, canvasWidth, canvasHeight, seed }
|
||||
)
|
||||
|
||||
// Wait for rendering to settle
|
||||
await this.page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
async setupStressTestBoard(shapeCount: number): Promise<void> {
|
||||
// Pre-configured stress test with maximum complexity
|
||||
await this.generateHeavyBoard({
|
||||
shapeCount,
|
||||
includeGroups: true,
|
||||
includeText: true,
|
||||
includeArrows: true,
|
||||
canvasWidth: 3000,
|
||||
canvasHeight: 2500,
|
||||
seed: 42, // Fixed seed for consistent tests
|
||||
})
|
||||
}
|
||||
|
||||
async getShapeCount(): Promise<number> {
|
||||
return await this.page.evaluate(() => {
|
||||
const editor = (window as any).editor
|
||||
return editor ? editor.getCurrentPageShapes().length : 0
|
||||
})
|
||||
}
|
||||
|
||||
async selectRandomShapes(count: number, seed: number = 12345): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
({ count, seed }) => {
|
||||
const editor = (window as any).editor
|
||||
if (!editor) return
|
||||
|
||||
// Simple seeded random number generator for reproducible tests
|
||||
class SeededRandom {
|
||||
private seed: number
|
||||
constructor(seed: number) {
|
||||
this.seed = seed
|
||||
}
|
||||
|
||||
next(): number {
|
||||
this.seed = (this.seed * 9301 + 49297) % 233280
|
||||
return this.seed / 233280
|
||||
}
|
||||
|
||||
range(min: number, max: number): number {
|
||||
return Math.floor(this.next() * (max - min + 1)) + min
|
||||
}
|
||||
}
|
||||
|
||||
const random = new SeededRandom(seed)
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
const selectedCount = Math.min(count, allShapes.length)
|
||||
|
||||
// Select random shapes using seeded random
|
||||
const shuffled = allShapes.sort(() => 0.5 - random.next())
|
||||
const selected = shuffled.slice(0, selectedCount)
|
||||
const shapeIds = selected.map((shape: any) => shape.id)
|
||||
|
||||
editor.setSelectedShapes(shapeIds)
|
||||
},
|
||||
{ count, seed }
|
||||
)
|
||||
}
|
||||
}
|
||||
42
apps/examples/e2e/fixtures/menus/ActionsMenu.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ActionsMenu {
|
||||
readonly quickActions: { [key: string]: Locator }
|
||||
readonly mainActions: { [key: string]: Locator }
|
||||
readonly actionsMenuButton: Locator
|
||||
readonly actionsMenuContent: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.actionsMenuButton = this.page.getByTestId('actions-menu.button')
|
||||
this.actionsMenuContent = this.page.getByTestId('actions-menu.content')
|
||||
this.quickActions = {
|
||||
undo: this.page.getByTestId('quick-actions.undo'),
|
||||
redo: this.page.getByTestId('quick-actions.redo'),
|
||||
delete: this.page.getByTestId('quick-actions.delete'),
|
||||
duplicate: this.page.getByTestId('quick-actions.duplicate'),
|
||||
}
|
||||
this.mainActions = {
|
||||
alignLeft: this.page.getByTestId('actions-menu.align-left'),
|
||||
alignCenterHorizontal: this.page.getByTestId('actions-menu.align-center-horizontal'),
|
||||
alignRight: this.page.getByTestId('actions-menu.align-right'),
|
||||
stretchHorizontal: this.page.getByTestId('actions-menu.stretch-horizontal'),
|
||||
alignTop: this.page.getByTestId('actions-menu.align-top'),
|
||||
alignCenterVertical: this.page.getByTestId('actions-menu.align-center-vertical'),
|
||||
alignBottom: this.page.getByTestId('actions-menu.align-bottom'),
|
||||
stretchVertical: this.page.getByTestId('actions-menu.stretch-vertical'),
|
||||
distributeHorizontal: this.page.getByTestId('actions-menu.distribute-horizontal'),
|
||||
distributeVertical: this.page.getByTestId('actions-menu.distribute-vertical'),
|
||||
stackHorizontal: this.page.getByTestId('actions-menu.stack-horizontal'),
|
||||
stackVertical: this.page.getByTestId('actions-menu.stack-vertical'),
|
||||
sendToBack: this.page.getByTestId('actions-menu.send-to-back'),
|
||||
sendBackward: this.page.getByTestId('actions-menu.send-backward'),
|
||||
bringForward: this.page.getByTestId('actions-menu.bring-forward'),
|
||||
bringToFront: this.page.getByTestId('actions-menu.bring-to-front'),
|
||||
rotateCCW: this.page.getByTestId('actions-menu.rotate-ccw'),
|
||||
rotateCW: this.page.getByTestId('actions-menu.rotate-cw'),
|
||||
editLink: this.page.getByTestId('actions-menu.edit-link'),
|
||||
group: this.page.getByTestId('actions-menu.group'),
|
||||
}
|
||||
}
|
||||
}
|
||||
34
apps/examples/e2e/fixtures/menus/HelpMenu.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class HelpMenu {
|
||||
readonly helpMenuButton: Locator
|
||||
readonly languagesButton: Locator
|
||||
readonly languagesContent: Locator
|
||||
readonly keyboardShortcutsMenu: KeyboardShortcutsMenu
|
||||
readonly linkGroup: { [key: string]: Locator }
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.helpMenuButton = this.page.getByTestId('help-menu.button')
|
||||
this.languagesButton = this.page.getByTestId('help-menu-sub.language-button')
|
||||
this.languagesContent = this.page.getByTestId('help-menu-sub.language-content')
|
||||
this.keyboardShortcutsMenu = new KeyboardShortcutsMenu(this.page)
|
||||
this.linkGroup = {
|
||||
github: this.page.getByTestId('help-menu.github'),
|
||||
twitter: this.page.getByTestId('help-menu.twitter'),
|
||||
discord: this.page.getByTestId('help-menu.discord'),
|
||||
about: this.page.getByTestId('help-menu.about'),
|
||||
}
|
||||
}
|
||||
}
|
||||
class KeyboardShortcutsMenu {
|
||||
readonly heading: Locator
|
||||
readonly button: Locator
|
||||
readonly closeButton: Locator
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.heading = this.page.getByRole('dialog').getByText('Keyboard shortcuts')
|
||||
this.button = this.page.getByTestId('help-menu.keyboard-shortcuts-button')
|
||||
this.closeButton = this.page.getByRole('dialog').getByTestId('dialog.close')
|
||||
}
|
||||
}
|
||||
62
apps/examples/e2e/fixtures/menus/MainMenu.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class MainMenu {
|
||||
readonly mainMenuButton: Locator
|
||||
readonly buttons: { [key: string]: Locator }
|
||||
readonly subMenus: { [key: string]: Locator[] }
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.mainMenuButton = this.page.getByTestId('main-menu.button')
|
||||
this.buttons = {
|
||||
edit: this.page.getByRole('menuitem').getByText('Edit'),
|
||||
object: this.page.getByRole('menuitem').getByText('Object'),
|
||||
view: this.page.getByRole('menuitem').getByText('View'),
|
||||
insertEmbed: this.page.getByRole('menuitem').getByText('Insert Embed'),
|
||||
uploadMedia: this.page.getByRole('menuitem').getByText('Upload Media'),
|
||||
preferences: this.page.getByRole('menuitem').getByText('Preferences'),
|
||||
}
|
||||
this.subMenus = {
|
||||
editSubmenu: [
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Undo'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Redo'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Cut'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Copy'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Copy as'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Duplicate'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Paste'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Delete'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Select all'),
|
||||
],
|
||||
objectSubmenu: [
|
||||
this.page.getByRole('menuitem').getByText('Export as'),
|
||||
this.page.getByRole('menuitem').getByText('Group'),
|
||||
this.page.getByRole('menuitem').getByText('Ungroup'),
|
||||
this.page.getByRole('menuitem').getByText('Remove frame'),
|
||||
this.page.getByRole('menuitem').getByText('Fit to content'),
|
||||
this.page.getByRole('menuitem').getByText('Toggle auto size'),
|
||||
this.page.getByRole('menuitem').getByText('Edit link'),
|
||||
this.page.getByRole('menuitem').getByText('Convert to Bookmark'),
|
||||
this.page.getByRole('menuitem').getByText('Convert to Embed'),
|
||||
this.page.getByRole('menuitem').getByText('Toggle locked'),
|
||||
this.page.getByRole('menuitem').getByText('Unlock all'),
|
||||
],
|
||||
viewSubmenu: [
|
||||
this.page.getByTestId('main-menu-sub.view-content').getByText('Zoom in'),
|
||||
this.page.getByTestId('main-menu-sub.view-content').getByText('Zoom out'),
|
||||
this.page.getByTestId('main-menu-sub.view-content').getByText('Zoom to 100%'),
|
||||
this.page.getByTestId('main-menu-sub.view-content').getByText('Zoom to fit'),
|
||||
this.page.getByTestId('main-menu-sub.view-content').getByText('Zoom to selection'),
|
||||
],
|
||||
preferencesSubmenu: [
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Always snap'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Tool lock'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Show grid'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Dark mode'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Focus mode'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Edge scrolling'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Reduce motion'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Language'),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
10
apps/examples/e2e/fixtures/menus/MenuClickCapture.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class MenuClickCapture {
|
||||
readonly content: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.content = this.page.getByTestId('menu-click-capture.content')
|
||||
}
|
||||
}
|
||||
21
apps/examples/e2e/fixtures/menus/NavigationPanel.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class NavigationPanel {
|
||||
readonly minimap: Locator
|
||||
readonly zoomMenuButton: Locator
|
||||
readonly toggleButton: Locator
|
||||
readonly zoomMenuItems: { [key: string]: Locator }
|
||||
constructor(private readonly page: Page) {
|
||||
this.page = page
|
||||
this.minimap = page.getByTestId('minimap.canvas')
|
||||
this.zoomMenuButton = page.getByTestId('minimap.zoom-menu-button')
|
||||
this.toggleButton = page.getByTestId('minimap.toggle-button')
|
||||
this.zoomMenuItems = {
|
||||
zoomIn: page.getByRole('menuitem').getByText('Zoom in'),
|
||||
zoomOut: page.getByRole('menuitem').getByText('Zoom out'),
|
||||
zoomToHundred: page.getByRole('menuitem').getByText('Zoom to 100%'),
|
||||
zoomToFit: page.getByRole('menuitem').getByText('Zoom to fit'),
|
||||
zoomToSelection: page.getByRole('menuitem').getByText('Zoom to selection'),
|
||||
}
|
||||
}
|
||||
}
|
||||
27
apps/examples/e2e/fixtures/menus/PageMenu.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class PageMenu {
|
||||
readonly pagemenuButton: Locator
|
||||
readonly header: Locator
|
||||
readonly createButton: Locator
|
||||
readonly pageList: Locator
|
||||
readonly pageItems: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.pagemenuButton = this.page.getByTestId('page-menu.button')
|
||||
// The popover no longer has a title row — use the list as the "is the menu open" anchor.
|
||||
this.header = this.page.getByTestId('page-menu.list')
|
||||
this.createButton = this.page.getByTestId('page-menu.create')
|
||||
this.pageList = this.page.getByTestId('page-menu.list')
|
||||
this.pageItems = this.page.getByTestId('page-menu.item')
|
||||
}
|
||||
|
||||
async getPageItem(index: number) {
|
||||
return this.pageItems.nth(index)
|
||||
}
|
||||
|
||||
getPageItemByName(name: string) {
|
||||
return this.page.getByTestId('page-menu.item').filter({ hasText: name })
|
||||
}
|
||||
}
|
||||
60
apps/examples/e2e/fixtures/menus/RichTextToolbar.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Locator, Page, expect } from '@playwright/test'
|
||||
|
||||
export class RichTextToolbar {
|
||||
container: Locator
|
||||
readonly tools: { [key: string]: Locator }
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.container = this.page.getByTestId('contextual-toolbar')
|
||||
this.tools = {
|
||||
bold: this.page.getByTestId('rich-text.bold'),
|
||||
italic: this.page.getByTestId('rich-text.italic'),
|
||||
code: this.page.getByTestId('rich-text.code'),
|
||||
strike: this.page.getByTestId('rich-text.strike'),
|
||||
link: this.page.getByTestId('rich-text.link'),
|
||||
bulletList: this.page.getByTestId('rich-text.bulletList'),
|
||||
highlight: this.page.getByTestId('rich-text.highlight'),
|
||||
linkRemove: this.page.getByTestId('rich-text.link-remove'),
|
||||
heading: this.page.getByTestId('rich-text.heading'),
|
||||
}
|
||||
}
|
||||
|
||||
async clickTool(tool: Locator) {
|
||||
await tool.click()
|
||||
}
|
||||
async isSelected(tool: Locator) {
|
||||
// pseudo elements aren't exposed to the DOM, but we can check the color as a proxy
|
||||
const expectedColor = 'rgb(46, 46, 46)'
|
||||
await expect(tool).toHaveCSS('color', expectedColor)
|
||||
}
|
||||
async isNotSelected(tool: Locator) {
|
||||
const expectedColor = 'rgb(255, 255, 255)'
|
||||
await expect(tool).toHaveCSS('color', expectedColor)
|
||||
}
|
||||
|
||||
async getSelectionBounds() {
|
||||
return this.page.evaluate(() => {
|
||||
const selection = window.getSelection()
|
||||
|
||||
// If there are no selections, don't return a box
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return
|
||||
|
||||
// Get a common box from all of the ranges' screen rects
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
for (let i = 0; i < selection.rangeCount; i++) {
|
||||
const range = selection.getRangeAt(i)
|
||||
const box = range.getBoundingClientRect()
|
||||
minX = Math.min(minX, box.left)
|
||||
minY = Math.min(minY, box.top)
|
||||
maxX = Math.max(maxX, box.right)
|
||||
maxY = Math.max(maxY, box.bottom)
|
||||
}
|
||||
|
||||
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }
|
||||
})
|
||||
}
|
||||
}
|
||||
88
apps/examples/e2e/fixtures/menus/StylePanel.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Locator, Page, expect } from '@playwright/test'
|
||||
|
||||
export class StylePanel {
|
||||
readonly stylesArray: string[]
|
||||
readonly colors: { [key: string]: Locator }
|
||||
readonly fill: { [key: string]: Locator }
|
||||
readonly fillExtra: Locator
|
||||
readonly dash: { [key: string]: Locator }
|
||||
readonly size: { [key: string]: Locator }
|
||||
readonly font: { [key: string]: Locator }
|
||||
readonly align: { [key: string]: Locator }
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.stylesArray = [
|
||||
'style.color',
|
||||
'style.opacity',
|
||||
'style.fill',
|
||||
'style.dash',
|
||||
'style.size',
|
||||
'style.font',
|
||||
'style.align',
|
||||
]
|
||||
this.colors = {
|
||||
black: this.page.getByTestId('style.color.black'),
|
||||
grey: this.page.getByTestId('style.color.grey'),
|
||||
lightViolet: this.page.getByTestId('style.color.light-violet'),
|
||||
violet: this.page.getByTestId('style.color.violet'),
|
||||
blue: this.page.getByTestId('style.color.blue'),
|
||||
lightBlue: this.page.getByTestId('style.color.light-blue'),
|
||||
yellow: this.page.getByTestId('style.color.yellow'),
|
||||
orange: this.page.getByTestId('style.color.orange'),
|
||||
green: this.page.getByTestId('style.color.green'),
|
||||
lightGreen: this.page.getByTestId('style.color.light-green'),
|
||||
lightRed: this.page.getByTestId('style.color.light-red'),
|
||||
red: this.page.getByTestId('style.color.red'),
|
||||
}
|
||||
this.fill = {
|
||||
none: this.page.getByTestId('style.fill.none'),
|
||||
semi: this.page.getByTestId('style.fill.semi'),
|
||||
solid: this.page.getByTestId('style.fill.solid'),
|
||||
}
|
||||
this.fillExtra = this.page.getByTestId('style.fill-extra')
|
||||
this.dash = {
|
||||
draw: this.page.getByTestId('style.dash.draw'),
|
||||
dashed: this.page.getByTestId('style.dash.dashed'),
|
||||
dotted: this.page.getByTestId('style.dash.dotted'),
|
||||
solid: this.page.getByTestId('style.dash.solid'),
|
||||
}
|
||||
this.size = {
|
||||
s: this.page.getByTestId('style.size.s'),
|
||||
m: this.page.getByTestId('style.size.m'),
|
||||
l: this.page.getByTestId('style.size.l'),
|
||||
xl: this.page.getByTestId('style.size.xl'),
|
||||
}
|
||||
this.font = {
|
||||
draw: this.page.getByTestId('style.font.draw'),
|
||||
sans: this.page.getByTestId('style.font.sans'),
|
||||
serif: this.page.getByTestId('style.font.serif'),
|
||||
mono: this.page.getByTestId('style.font.mono'),
|
||||
}
|
||||
|
||||
this.align = {
|
||||
start: this.page.getByTestId('style.align.start'),
|
||||
middle: this.page.getByTestId('style.align.middle'),
|
||||
end: this.page.getByTestId('style.align.end'),
|
||||
}
|
||||
}
|
||||
async getAfterElementStyle(style: Locator, property: string) {
|
||||
const getStyle = (element: Element, property: string) =>
|
||||
window.getComputedStyle(element, '::after').getPropertyValue(property)
|
||||
return await style.evaluate(getStyle, property)
|
||||
}
|
||||
|
||||
async isActive(style: Locator) {
|
||||
const backgroundColor = await this.getAfterElementStyle(style, 'background-color')
|
||||
return expect(backgroundColor).toBe('rgba(0, 0, 0, 0.055)')
|
||||
}
|
||||
|
||||
async isInactive(style: Locator) {
|
||||
const backgroundColor = await this.getAfterElementStyle(style, 'background-color')
|
||||
// The color is different on mobile
|
||||
return expect(['rgba(0, 0, 0, 0.043)', 'rgba(0, 0, 0, 0)']).toContain(backgroundColor)
|
||||
}
|
||||
getElement() {
|
||||
return this.page.getByTestId('style.panel')
|
||||
}
|
||||
}
|
||||
45
apps/examples/e2e/fixtures/menus/Toolbar.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Locator, Page, expect } from '@playwright/test'
|
||||
|
||||
export class Toolbar {
|
||||
readonly toolLock: Locator
|
||||
readonly moreToolsButton: Locator
|
||||
readonly moreToolsPopover: Locator
|
||||
readonly mobileStylesButton: Locator
|
||||
readonly tools: { [key: string]: Locator }
|
||||
readonly popOverTools: { [key: string]: Locator }
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.toolLock = this.page.getByTestId('tool-lock')
|
||||
this.moreToolsButton = this.page.getByTestId('tools.more-button')
|
||||
this.moreToolsPopover = this.page.getByTestId('tools.more-content')
|
||||
this.mobileStylesButton = this.page.getByTestId('mobile-styles.button')
|
||||
this.tools = {
|
||||
select: this.page.getByTestId('tools.select'),
|
||||
draw: this.page.getByTestId('tools.draw'),
|
||||
text: this.page.getByTestId('tools.text'),
|
||||
arrow: this.page.getByTestId('tools.arrow'),
|
||||
cloud: this.page.getByTestId('tools.cloud'),
|
||||
eraser: this.page.getByTestId('tools.eraser'),
|
||||
rectangle: this.page.getByTestId('tools.rectangle'),
|
||||
hand: this.page.getByTestId('tools.hand'),
|
||||
}
|
||||
this.popOverTools = {
|
||||
popoverCloud: this.page.getByTestId('tools.more.cloud'),
|
||||
popoverFrame: this.page.getByTestId('tools.more.frame'),
|
||||
popoverRectangle: this.page.getByTestId('tools.more.rectangle'),
|
||||
}
|
||||
}
|
||||
async clickTool(tool: Locator) {
|
||||
await tool.click()
|
||||
}
|
||||
async isSelected(tool: Locator) {
|
||||
// pseudo elements aren't exposed to the DOM, but we can check the color as a proxy
|
||||
const expectedColor = 'rgb(255, 255, 255)'
|
||||
await expect(tool).toHaveCSS('color', expectedColor)
|
||||
}
|
||||
async isNotSelected(tool: Locator) {
|
||||
const expectedColor = 'rgb(46, 46, 46)'
|
||||
await expect(tool).toHaveCSS('color', expectedColor)
|
||||
}
|
||||
}
|
||||
645
apps/examples/e2e/fixtures/perf-utils.ts
Normal file
@@ -0,0 +1,645 @@
|
||||
import { Page, PlaywrightTestArgs } from '@playwright/test'
|
||||
import { Editor } from 'tldraw'
|
||||
import { BaselineManager, ComparisonResult, Environment } from './baseline-manager'
|
||||
import { E2EFpsTracker, FPSMetrics } from './fps-tracker'
|
||||
import { HeavyBoardGenerator } from './heavy-board-generator'
|
||||
|
||||
export interface PerformanceTestResult {
|
||||
interaction: string
|
||||
metrics: FPSMetrics
|
||||
comparison: ComparisonResult
|
||||
environment: Environment
|
||||
}
|
||||
|
||||
export interface InteractionTestOptions {
|
||||
warmupMs?: number
|
||||
measureMs?: number
|
||||
expectMinFps?: number
|
||||
}
|
||||
|
||||
export class PerformanceTestSuite {
|
||||
private fpsTracker: E2EFpsTracker
|
||||
private baselineManager: BaselineManager
|
||||
private boardGenerator: HeavyBoardGenerator
|
||||
private environment: Environment
|
||||
|
||||
constructor(page: Page, browserName: string) {
|
||||
this.fpsTracker = new E2EFpsTracker(page)
|
||||
this.baselineManager = new BaselineManager()
|
||||
this.boardGenerator = new HeavyBoardGenerator(page)
|
||||
|
||||
// Detect environment
|
||||
this.environment = {
|
||||
platform: process.platform,
|
||||
viewport: '1920x1080', // Will be updated based on actual viewport
|
||||
browser: browserName,
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(page: Page): Promise<void> {
|
||||
// Update viewport information
|
||||
const viewport = page.viewportSize()
|
||||
if (viewport) {
|
||||
this.environment.viewport = `${viewport.width}x${viewport.height}`
|
||||
}
|
||||
|
||||
// Set up the page for performance testing
|
||||
await page.goto('http://localhost:5420/end-to-end')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
|
||||
// Disable animations for consistent testing
|
||||
await page.evaluate(() => {
|
||||
const editor: Editor = (window as any).editor
|
||||
if (editor) {
|
||||
editor.user.updateUserPreferences({ animationSpeed: 0 })
|
||||
}
|
||||
})
|
||||
|
||||
// Let the editor settle
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
async setupHeavyBoard(shapeCount = 200): Promise<void> {
|
||||
await this.boardGenerator.setupStressTestBoard(shapeCount)
|
||||
|
||||
// Ensure the board is fully rendered
|
||||
await this.waitForStableFramerate()
|
||||
}
|
||||
|
||||
private async waitForStableFramerate(maxWaitMs = 3000): Promise<void> {
|
||||
// Wait for the framerate to stabilize after board generation
|
||||
const startTime = Date.now()
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
// Check if we're getting consistent frame times
|
||||
const isStable = await this.checkFramerateStability()
|
||||
if (isStable) break
|
||||
}
|
||||
}
|
||||
|
||||
private async checkFramerateStability(): Promise<boolean> {
|
||||
// Simple heuristic: check if the page has settled
|
||||
return true // For now, just return true. Could be enhanced with actual stability detection
|
||||
}
|
||||
|
||||
async testShapeRotation(options: InteractionTestOptions = {}): Promise<PerformanceTestResult> {
|
||||
const { warmupMs = 500, measureMs = 10000, expectMinFps } = options // 10 seconds for lots of rotation
|
||||
|
||||
const { metrics } = await this.fpsTracker.measureInteraction(
|
||||
async () => {
|
||||
const page = this.getPage()
|
||||
|
||||
// Select all shapes for rotation
|
||||
await page.keyboard.press('Control+a')
|
||||
|
||||
// Wait a moment for selection
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
// Perform continuous rotations using the editor API
|
||||
await page.evaluate(() => {
|
||||
const editor: Editor = (window as any).editor
|
||||
if (!editor) return
|
||||
|
||||
// Rotate shapes continuously during measurement period
|
||||
// 10 seconds at 60fps with 5° steps = ~8.7 rotations total!
|
||||
let totalRotations = 0
|
||||
const rotateStep = Math.PI / 36 // 5 degrees per step
|
||||
const degreesPerStep = 5
|
||||
|
||||
const rotateInterval = setInterval(() => {
|
||||
const selectedShapes = editor.getSelectedShapes()
|
||||
if (selectedShapes.length > 0) {
|
||||
editor.rotateShapesBy(selectedShapes, rotateStep)
|
||||
totalRotations += degreesPerStep / 360 // Track full rotations
|
||||
;(window as any).__totalRotations = totalRotations
|
||||
}
|
||||
}, 16) // ~60fps rotation updates
|
||||
|
||||
// Store interval ID and stats so we can clean it up
|
||||
;(window as any).__rotationInterval = rotateInterval
|
||||
})
|
||||
},
|
||||
{ warmupMs, measureMs }
|
||||
)
|
||||
|
||||
// Clean up and clear selection
|
||||
const page = this.getPage()
|
||||
|
||||
// Stop the rotation interval and get stats
|
||||
const rotationStats = await page.evaluate(() => {
|
||||
const interval = (window as any).__rotationInterval
|
||||
const totalRotations = (window as any).__totalRotations || 0
|
||||
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
delete (window as any).__rotationInterval
|
||||
delete (window as any).__totalRotations
|
||||
}
|
||||
|
||||
return { totalRotations: Math.round(totalRotations * 10) / 10 } // Round to 1 decimal
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`🔄 Completed ${rotationStats.totalRotations} rotations during measurement`)
|
||||
|
||||
// Clear selection
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
return this.finalizeTestResult('rotate_shapes', metrics, expectMinFps)
|
||||
}
|
||||
|
||||
async testShapeDragging(options: InteractionTestOptions = {}): Promise<PerformanceTestResult> {
|
||||
const { warmupMs = 500, measureMs = 3000, expectMinFps } = options
|
||||
|
||||
// Select shapes for dragging
|
||||
await this.boardGenerator.selectRandomShapes(5)
|
||||
|
||||
const { metrics } = await this.fpsTracker.measureInteraction(
|
||||
async () => {
|
||||
const page = this.getPage()
|
||||
|
||||
// Select all shapes for rotation
|
||||
await page.keyboard.press('Control+a')
|
||||
|
||||
// Drag shapes around the canvas
|
||||
const startX = 400
|
||||
const startY = 400
|
||||
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
|
||||
// Drag in various directions
|
||||
const movements = [
|
||||
[600, 300],
|
||||
[800, 500],
|
||||
[500, 700],
|
||||
[300, 600],
|
||||
[400, 400],
|
||||
[500, 500],
|
||||
[700, 700],
|
||||
[900, 900],
|
||||
[1100, 1100],
|
||||
[1300, 1300],
|
||||
[200, 200],
|
||||
[324, 324],
|
||||
[448, 448],
|
||||
[572, 572],
|
||||
[696, 696],
|
||||
[820, 820],
|
||||
]
|
||||
|
||||
for (const [x, y] of movements) {
|
||||
await page.mouse.move(x, y, { steps: 10 })
|
||||
await page.waitForTimeout(16) // ~60fps
|
||||
}
|
||||
|
||||
await page.mouse.up()
|
||||
},
|
||||
{ warmupMs, measureMs }
|
||||
)
|
||||
|
||||
// Clear selection
|
||||
const page = this.getPage()
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
return this.finalizeTestResult('drag_shapes', metrics, expectMinFps)
|
||||
}
|
||||
|
||||
async testShapeResizing(options: InteractionTestOptions = {}): Promise<PerformanceTestResult> {
|
||||
const { warmupMs = 500, measureMs = 3000, expectMinFps } = options
|
||||
|
||||
const { metrics } = await this.fpsTracker.measureInteraction(
|
||||
async () => {
|
||||
const page = this.getPage()
|
||||
|
||||
// Select all shapes for resizing
|
||||
await page.keyboard.press('Control+a')
|
||||
|
||||
// Use editor API to programmatically resize selected shapes
|
||||
await page.evaluate(() => {
|
||||
const editor: Editor = (window as any).editor
|
||||
if (!editor) return
|
||||
|
||||
// Simple seeded random number generator for consistent testing
|
||||
let seed = 12345
|
||||
function seededRandom() {
|
||||
seed = (seed * 9301 + 49297) % 233280
|
||||
return seed / 233280
|
||||
}
|
||||
|
||||
let resizeCycle = 0
|
||||
const resizeInterval = setInterval(() => {
|
||||
const selectedShapes = editor.getSelectedShapes()
|
||||
if (selectedShapes.length === 0) return
|
||||
|
||||
// Generate seeded random scale factors
|
||||
let scaleFactor: number
|
||||
if (resizeCycle % 2 === 0) {
|
||||
// Scale up: random between 1.1 and 5
|
||||
scaleFactor = 1.1 + seededRandom() * (2.0 - 1.1)
|
||||
} else {
|
||||
// Scale down: random between 0.2 and 0.9
|
||||
scaleFactor = 0.5 + seededRandom() * (0.9 - 0.5)
|
||||
}
|
||||
|
||||
// Get current bounds for the scale origin
|
||||
const bounds = editor.getSelectionPageBounds()
|
||||
if (bounds) {
|
||||
// Resize each selected shape individually
|
||||
selectedShapes.forEach((shape: any) => {
|
||||
editor.resizeShape(
|
||||
shape.id,
|
||||
{ x: scaleFactor, y: scaleFactor },
|
||||
{
|
||||
scaleOrigin: bounds.center,
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
resizeCycle++
|
||||
}, 16) // Resize every 16ms for ~60fps
|
||||
|
||||
;(window as any).__resizeInterval = resizeInterval
|
||||
})
|
||||
},
|
||||
{ warmupMs, measureMs }
|
||||
)
|
||||
|
||||
// Stop the resize interval
|
||||
const page = this.getPage()
|
||||
await page.evaluate(() => {
|
||||
const interval = (window as any).__resizeInterval
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
delete (window as any).__resizeInterval
|
||||
}
|
||||
})
|
||||
|
||||
// Clear selection
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
return this.finalizeTestResult('resize_shapes', metrics, expectMinFps)
|
||||
}
|
||||
|
||||
async testCanvasPanning(options: InteractionTestOptions = {}): Promise<PerformanceTestResult> {
|
||||
const { warmupMs = 500, measureMs = 3000, expectMinFps } = options
|
||||
|
||||
const { metrics } = await this.fpsTracker.measureInteraction(
|
||||
async () => {
|
||||
const page = this.getPage()
|
||||
|
||||
// Pan around the canvas using Editor camera API
|
||||
await page.evaluate(() => {
|
||||
const editor: Editor = (window as any).editor
|
||||
if (!editor) return
|
||||
|
||||
// Get initial camera position
|
||||
const initialCamera = editor.getCamera()
|
||||
let panCycle = 0
|
||||
|
||||
// Define pan movements as offsets from initial position
|
||||
const panOffsets = [
|
||||
{ x: -1800, y: -200 },
|
||||
{ x: 400, y: -1800 },
|
||||
{ x: 800, y: 1200 },
|
||||
{ x: -900, y: 800 },
|
||||
{ x: 0, y: 0 },
|
||||
{ x: -3600, y: -3600 },
|
||||
{ x: -900, y: -1900 },
|
||||
{ x: -900, y: 1200 },
|
||||
{ x: 300, y: 800 },
|
||||
{ x: -1800, y: -800 },
|
||||
{ x: 400, y: -1800 },
|
||||
{ x: 800, y: 1200 },
|
||||
{ x: -900, y: 800 },
|
||||
{ x: 0, y: 0 },
|
||||
{ x: -3600, y: -3600 },
|
||||
{ x: -900, y: -1900 },
|
||||
{ x: -900, y: 1200 },
|
||||
{ x: 300, y: 800 },
|
||||
]
|
||||
|
||||
const panInterval = setInterval(() => {
|
||||
const offsetIndex = panCycle % panOffsets.length
|
||||
const offset = panOffsets[offsetIndex]
|
||||
|
||||
// Set camera position to initial position plus offset
|
||||
editor.setCamera({
|
||||
x: initialCamera.x + offset.x,
|
||||
y: initialCamera.y + offset.y,
|
||||
z: initialCamera.z,
|
||||
})
|
||||
|
||||
panCycle++
|
||||
}, 16) // Update every 16ms for ~60fps
|
||||
|
||||
;(window as any).__panInterval = panInterval
|
||||
})
|
||||
},
|
||||
{ warmupMs, measureMs }
|
||||
)
|
||||
|
||||
// Stop the pan interval
|
||||
const page = this.getPage()
|
||||
await page.evaluate(() => {
|
||||
const interval = (window as any).__panInterval
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
delete (window as any).__panInterval
|
||||
}
|
||||
})
|
||||
|
||||
return this.finalizeTestResult('canvas_panning', metrics, expectMinFps)
|
||||
}
|
||||
|
||||
async testCanvasZooming(options: InteractionTestOptions = {}): Promise<PerformanceTestResult> {
|
||||
const { warmupMs = 500, measureMs = 10000, expectMinFps } = options // Shorter measure time for zooming
|
||||
|
||||
const { metrics } = await this.fpsTracker.measureInteraction(
|
||||
async () => {
|
||||
const page = this.getPage()
|
||||
|
||||
// Use editor API to zoom in and out continuously
|
||||
await page.evaluate(() => {
|
||||
const editor: Editor = (window as any).editor
|
||||
if (!editor) return
|
||||
|
||||
let zoomCycle = 0
|
||||
const zoomInterval = setInterval(() => {
|
||||
const cycle = Math.floor(zoomCycle / 10) % 2 // Switch every 10 steps
|
||||
if (cycle === 0) {
|
||||
editor.zoomIn() // Zoom in phase
|
||||
} else {
|
||||
editor.zoomOut() // Zoom out phase
|
||||
}
|
||||
zoomCycle++
|
||||
}, 16) // Zoom every 16ms
|
||||
|
||||
;(window as any).__zoomInterval = zoomInterval
|
||||
})
|
||||
},
|
||||
{ warmupMs, measureMs }
|
||||
)
|
||||
|
||||
// Stop the zoom interval
|
||||
const page = this.getPage()
|
||||
await page.evaluate(() => {
|
||||
const interval = (window as any).__zoomInterval
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
delete (window as any).__zoomInterval
|
||||
}
|
||||
})
|
||||
|
||||
return this.finalizeTestResult('canvas_zooming', metrics, expectMinFps)
|
||||
}
|
||||
|
||||
async testTextShapeCreation(
|
||||
options: InteractionTestOptions = {}
|
||||
): Promise<PerformanceTestResult> {
|
||||
const { warmupMs = 500, measureMs = 10000, expectMinFps } = options
|
||||
|
||||
const { metrics } = await this.fpsTracker.measureInteraction(
|
||||
async () => {
|
||||
const page = this.getPage()
|
||||
|
||||
// Repeatedly create text shapes and frames during the measurement
|
||||
// window. Each cycle deletes the previous batch so text layouts
|
||||
// can't be cached, forcing TextManager.measureText /
|
||||
// measureTextSpans to run every time. The samples mix ASCII with
|
||||
// emoji ZWJ sequences, flags, and accents so the grapheme
|
||||
// segmenter path is exercised. See
|
||||
// https://github.com/tldraw/tldraw/issues/9112.
|
||||
await page.evaluate(() => {
|
||||
const editor = (window as any).editor
|
||||
const tldrawApi = (window as any).tldrawApi
|
||||
if (!editor || !tldrawApi) return
|
||||
|
||||
const textSamples = [
|
||||
'Performance text measurement',
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing',
|
||||
'Hello 👨👩👧 family',
|
||||
'Flag 🇫🇷 with accents café résumé',
|
||||
'Multi-line\ntext content\nwith line breaks',
|
||||
'Emoji 👍🏽 skin tone modifier 🎉 party',
|
||||
]
|
||||
|
||||
// Seeded RNG for reproducible layouts.
|
||||
let seed = 9112
|
||||
const next = () => {
|
||||
seed = (seed * 9301 + 49297) % 233280
|
||||
return seed / 233280
|
||||
}
|
||||
const range = (min: number, max: number) => Math.floor(next() * (max - min + 1)) + min
|
||||
const choice = <T>(arr: T[]): T => arr[range(0, arr.length - 1)]
|
||||
const sizes = ['s', 'm', 'l', 'xl']
|
||||
|
||||
let batch = 0
|
||||
const createInterval = setInterval(() => {
|
||||
const prev = editor.getCurrentPageShapes()
|
||||
if (prev.length) {
|
||||
editor.deleteShapes(prev.map((s: any) => s.id))
|
||||
}
|
||||
|
||||
const shapes: any[] = []
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const sample = `${choice(textSamples)} ${i}`
|
||||
shapes.push({
|
||||
id: `shape:text-${batch}-${i}`,
|
||||
type: 'text',
|
||||
x: range(0, 1600),
|
||||
y: range(0, 1000),
|
||||
props: {
|
||||
richText: tldrawApi.toRichText(sample),
|
||||
size: choice(sizes),
|
||||
font: 'draw',
|
||||
},
|
||||
})
|
||||
}
|
||||
// Frames whose names end in emoji/accents exercise the
|
||||
// title measurement path from the original bug report.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
shapes.push({
|
||||
id: `shape:frame-${batch}-${i}`,
|
||||
type: 'frame',
|
||||
x: range(0, 1600),
|
||||
y: range(0, 1000),
|
||||
props: { w: 320, h: 220, name: `Frame ${choice(textSamples)}` },
|
||||
})
|
||||
}
|
||||
|
||||
editor.createShapes(shapes)
|
||||
batch++
|
||||
}, 100)
|
||||
|
||||
;(window as any).__textCreateInterval = createInterval
|
||||
})
|
||||
},
|
||||
{ warmupMs, measureMs }
|
||||
)
|
||||
|
||||
// Stop the creation interval and clean up.
|
||||
const page = this.getPage()
|
||||
await page.evaluate(() => {
|
||||
const interval = (window as any).__textCreateInterval
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
delete (window as any).__textCreateInterval
|
||||
}
|
||||
})
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
return this.finalizeTestResult('create_text_shapes', metrics, expectMinFps)
|
||||
}
|
||||
|
||||
async runFullPerformanceTest(): Promise<PerformanceTestResult[]> {
|
||||
const results: PerformanceTestResult[] = []
|
||||
|
||||
// Run all performance tests
|
||||
await this.setupHeavyBoard()
|
||||
results.push(await this.testShapeRotation())
|
||||
await this.setupHeavyBoard()
|
||||
results.push(await this.testShapeDragging())
|
||||
await this.setupHeavyBoard(50)
|
||||
results.push(await this.testShapeResizing())
|
||||
await this.setupHeavyBoard(1000)
|
||||
results.push(await this.testCanvasPanning())
|
||||
await this.setupHeavyBoard()
|
||||
results.push(await this.testCanvasZooming())
|
||||
results.push(await this.testTextShapeCreation())
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
updateBaseline(interaction: string, metrics: FPSMetrics, force = false): void {
|
||||
this.baselineManager.updateBaseline(interaction, metrics, this.environment, force)
|
||||
}
|
||||
|
||||
getBaseline(interaction: string) {
|
||||
return this.baselineManager.getBaseline(interaction, this.environment)
|
||||
}
|
||||
|
||||
setThresholds(regressionThreshold: number, warningThreshold: number): void {
|
||||
this.baselineManager.setThresholds(regressionThreshold, warningThreshold)
|
||||
}
|
||||
|
||||
// Helper method to control baseline auto-creation
|
||||
async runTestWithBaseline(
|
||||
testFn: () => Promise<{ metrics: FPSMetrics }>,
|
||||
interaction: string,
|
||||
autoCreate = true
|
||||
): Promise<PerformanceTestResult> {
|
||||
const { metrics } = await testFn()
|
||||
const comparison = this.baselineManager.compareWithBaseline(
|
||||
interaction,
|
||||
metrics,
|
||||
this.environment,
|
||||
autoCreate
|
||||
)
|
||||
|
||||
return {
|
||||
interaction,
|
||||
metrics,
|
||||
comparison,
|
||||
environment: this.environment,
|
||||
}
|
||||
}
|
||||
|
||||
// Common method for finalizing performance test results
|
||||
private finalizeTestResult(
|
||||
interaction: string,
|
||||
metrics: FPSMetrics,
|
||||
expectMinFps?: number
|
||||
): PerformanceTestResult {
|
||||
const comparison = this.baselineManager.compareWithBaseline(
|
||||
interaction,
|
||||
metrics,
|
||||
this.environment
|
||||
)
|
||||
|
||||
// Validate minimum FPS if specified
|
||||
if (expectMinFps && metrics.minFps < expectMinFps) {
|
||||
comparison.status = 'fail'
|
||||
comparison.message += ` Minimum FPS ${metrics.minFps} below expected ${expectMinFps}.`
|
||||
}
|
||||
|
||||
return {
|
||||
interaction,
|
||||
metrics,
|
||||
comparison,
|
||||
environment: this.environment,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get page reference from fps tracker
|
||||
private getPage(): Page {
|
||||
return (this.fpsTracker as any).page as Page
|
||||
}
|
||||
|
||||
// Method to manually create/update baselines
|
||||
async establishBaselines(): Promise<void> {
|
||||
await this.setupHeavyBoard()
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('🔄 Establishing performance baselines...')
|
||||
|
||||
const interactions = [
|
||||
{ name: 'rotate_shapes', testFn: () => this.testShapeRotation() },
|
||||
{ name: 'drag_shapes', testFn: () => this.testShapeDragging() },
|
||||
{ name: 'resize_shapes', testFn: () => this.testShapeResizing() },
|
||||
{ name: 'canvas_panning', testFn: () => this.testCanvasPanning() },
|
||||
{ name: 'canvas_zooming', testFn: () => this.testCanvasZooming() },
|
||||
{ name: 'create_text_shapes', testFn: () => this.testTextShapeCreation() },
|
||||
]
|
||||
|
||||
for (const { name, testFn } of interactions) {
|
||||
const result = await testFn()
|
||||
this.updateBaseline(name, result.metrics, true)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`✅ Baseline set for ${name}: ${result.metrics.averageFps} FPS`)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('🎉 All baselines established!')
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to set up page for performance testing
|
||||
export async function setupPerformanceTest(
|
||||
{ page }: PlaywrightTestArgs,
|
||||
browserName: string
|
||||
): Promise<PerformanceTestSuite> {
|
||||
const perfSuite = new PerformanceTestSuite(page, browserName)
|
||||
await perfSuite.initialize(page)
|
||||
return perfSuite
|
||||
}
|
||||
|
||||
// Helper function to format performance results for reporting
|
||||
export function formatPerformanceResults(results: PerformanceTestResult[]): string {
|
||||
let report = '\n=== Performance Test Results ===\n'
|
||||
|
||||
for (const result of results) {
|
||||
const { interaction, metrics, comparison } = result
|
||||
const status = comparison.status.toUpperCase()
|
||||
const statusEmoji =
|
||||
comparison.status === 'pass' ? '✅' : comparison.status === 'warning' ? '⚠️' : '❌'
|
||||
|
||||
report += `\n${statusEmoji} ${interaction.replace('_', ' ').toUpperCase()} [${status}]\n`
|
||||
report += ` Average FPS: ${metrics.averageFps} (${comparison.avgFpsChange > 0 ? '+' : ''}${comparison.avgFpsChange.toFixed(1)}%)\n`
|
||||
report += ` Min FPS: ${metrics.minFps}, Max FPS: ${metrics.maxFps}\n`
|
||||
report += ` Samples: ${metrics.samples.length}, Duration: ${metrics.duration.toFixed(1)}s\n`
|
||||
report += ` ${comparison.message}\n`
|
||||
}
|
||||
|
||||
const failCount = results.filter((r) => r.comparison.status === 'fail').length
|
||||
const warningCount = results.filter((r) => r.comparison.status === 'warning').length
|
||||
const passCount = results.filter((r) => r.comparison.status === 'pass').length
|
||||
|
||||
report += `\n=== Summary ===\n`
|
||||
report += ` ✅ Passed: ${passCount}\n`
|
||||
report += ` ⚠️ Warnings: ${warningCount}\n`
|
||||
report += ` ❌ Failed: ${failCount}\n`
|
||||
|
||||
return report
|
||||
}
|
||||
7
apps/examples/e2e/global-setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { FullConfig } from '@playwright/test'
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
return config
|
||||
}
|
||||
|
||||
export default globalSetup
|
||||
8
apps/examples/e2e/global-teardown.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { FullConfig } from '@playwright/test'
|
||||
|
||||
async function globalTeardown(config: FullConfig) {
|
||||
// noop
|
||||
return config
|
||||
}
|
||||
|
||||
export default globalTeardown
|
||||
@@ -0,0 +1,161 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import mermaidDefinitions from '../../src/examples/use-cases/hundred-mermaids/mermaids'
|
||||
import type { MermaidReadabilityFinding } from '../../src/misc/mermaidReadability'
|
||||
import test from '../fixtures/fixtures'
|
||||
import { setupPage } from '../shared-e2e'
|
||||
|
||||
// Converts every diagram in the "Hundreds of Mermaid diagrams" example and compares the result with
|
||||
// mermaid's own rendering (see `src/misc/mermaidReadability.ts`). Not part of `yarn e2e`: CI runs it
|
||||
// only when mermaid conversion changes (`.github/workflows/playwright-mermaid.yml`), and locally it
|
||||
// is `yarn e2e-mermaid-readability` from `apps/examples`.
|
||||
|
||||
// `mermaids.ts` groups its definitions by diagram type, in this order.
|
||||
const DIAGRAM_TYPES = ['flowchart', 'state', 'sequence', 'mindmap'] as const
|
||||
type DiagramType = (typeof DIAGRAM_TYPES)[number]
|
||||
|
||||
// `index` is the diagram's position within its group in `mermaids.ts`.
|
||||
type Finding = { diagram: DiagramType; index: number } & MermaidReadabilityFinding
|
||||
|
||||
function overlap(diagram: DiagramType, index: number, label: string, over: string): Finding {
|
||||
return { diagram, index, check: 'overlap', label, over }
|
||||
}
|
||||
|
||||
function midWordBreak(diagram: DiagramType, index: number, label: string, word: string): Finding {
|
||||
return { diagram, index, check: 'mid-word break', label, word }
|
||||
}
|
||||
|
||||
function missingText(diagram: DiagramType, index: number, text: string): Finding {
|
||||
return { diagram, index, check: 'missing text', text }
|
||||
}
|
||||
|
||||
interface KnownProblem {
|
||||
reason: string
|
||||
findings: Finding[]
|
||||
}
|
||||
|
||||
// Problems that exist today. An entry that stops being found fails the check too, so the list
|
||||
// shrinks as they are fixed. Like the e2e snapshots, it matches chromium on Linux, where CI runs:
|
||||
// text metrics differ by a pixel elsewhere, so a local run on macOS can disagree on an entry that
|
||||
// is a pixel from colliding.
|
||||
const KNOWN_PROBLEMS: KnownProblem[] = [
|
||||
{
|
||||
// Not a bug: a label is part of its arrow, so a self-message's sits on its loop. Placing it
|
||||
// above the loop, as mermaid does, would mean detaching it. Closed as intended in #10796.
|
||||
reason: 'Sequence self-message labels are centered on their loop, over the lifeline',
|
||||
findings: [
|
||||
overlap('sequence', 6, 'Recompute backoff', 'line'),
|
||||
overlap('sequence', 9, 'Recalculate cache', 'line'),
|
||||
overlap('sequence', 11, 'Apply discount', 'opt [Promo code provided]'),
|
||||
overlap('sequence', 24, '9 Apply discount rules', 'line'),
|
||||
overlap('sequence', 24, '9 Apply discount rules', 'rectangle with no text'),
|
||||
overlap('sequence', 24, '9 Apply discount rules', 'opt [Customer included coupon]'),
|
||||
overlap('sequence', 25, 'Recompute eviction policy', 'line'),
|
||||
overlap('sequence', 28, 'Retry parsing malformed input', 'line'),
|
||||
overlap('sequence', 28, 'Retry parsing malformed input', 'rectangle with no text'),
|
||||
overlap(
|
||||
'sequence',
|
||||
29,
|
||||
'perform an unusually long internal bookkeeping step with a very wide label',
|
||||
'line'
|
||||
),
|
||||
overlap(
|
||||
'sequence',
|
||||
32,
|
||||
'parse chunk 3 with a suspiciously long status label for layout testing',
|
||||
'line'
|
||||
),
|
||||
overlap(
|
||||
'sequence',
|
||||
32,
|
||||
'parse chunk 3 with a suspiciously long status label for layout testing',
|
||||
'rectangle with no text'
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
// Listed as a known limitation in #10773.
|
||||
reason: 'A self-loop on the top or bottom of a narrow state node gets too little label width',
|
||||
findings: [midWordBreak('state', 21, 'type character', 'chara/cter')],
|
||||
},
|
||||
{
|
||||
// Mermaid lays edge labels out for a smaller font than the node size they are drawn at, so a
|
||||
// label near a composite state's edge can reach past it. Accepted in #10804, which drew them at
|
||||
// the node size so they read at the same size as the nodes.
|
||||
reason: 'An edge label near a composite state reaches past its edge',
|
||||
findings: [overlap('state', 19, 'manager rejects', 'Review')],
|
||||
},
|
||||
{
|
||||
reason:
|
||||
'Frame titles are drawn at the top left of the frame, where an activation bar runs through them',
|
||||
findings: [
|
||||
'par [Fetch product data]',
|
||||
'[Check stock]',
|
||||
'opt [Customer included coupon]',
|
||||
'critical [Commit purchase]',
|
||||
'[Payment failed]',
|
||||
'par [Notify customer]',
|
||||
'[Track analytics]',
|
||||
].map((label) => overlap('sequence', 24, label, 'rectangle with no text')),
|
||||
},
|
||||
{
|
||||
reason: 'A note in an `option` section is drawn over the section title',
|
||||
findings: [overlap('sequence', 24, 'Stop flow before inventory mutation', '[Payment failed]')],
|
||||
},
|
||||
{
|
||||
reason:
|
||||
"A section title's box is taller than its text, so a frame nested right under it starts inside the box",
|
||||
findings: [overlap('sequence', 22, 'opt [New device detected]', '[Optional alert]')],
|
||||
},
|
||||
{
|
||||
// Not a conversion bug: inside a composite state, mermaid ignores a `<<fork>>` or `<<join>>`
|
||||
// declared after the state is first used and draws a plain named state. The conversion draws
|
||||
// the bar the source asks for.
|
||||
reason: 'Mermaid draws these forks and joins as named states',
|
||||
findings: [
|
||||
missingText('state', 11, 'Fork'),
|
||||
missingText('state', 11, 'Join'),
|
||||
missingText('state', 20, 'F'),
|
||||
missingText('state', 20, 'J'),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function getKey(finding: Finding) {
|
||||
return JSON.stringify(finding, Object.keys(finding).sort())
|
||||
}
|
||||
|
||||
test.describe('Mermaid readability', () => {
|
||||
test.skip(({ isMobile }) => isMobile, 'Readability is measured at desktop size only')
|
||||
|
||||
DIAGRAM_TYPES.forEach((diagram, group) => {
|
||||
test(`${diagram} diagrams`, async ({ page, api }) => {
|
||||
test.setTimeout(120_000)
|
||||
await setupPage(page)
|
||||
await api.preloadFonts()
|
||||
// A font that fails to load is swapped for a system one, whose metrics wrap text
|
||||
// differently. That would surface below as breaks and overlaps, not as a font problem.
|
||||
const failedFonts = await page.evaluate(() => [
|
||||
...new Set(
|
||||
[...document.fonts].filter((font) => font.status === 'error').map((font) => font.family)
|
||||
),
|
||||
])
|
||||
expect(failedFonts).toEqual([])
|
||||
|
||||
const found: Finding[] = []
|
||||
for (const [index, definition] of mermaidDefinitions[group].entries()) {
|
||||
const findings = await api.checkMermaidReadability(definition)
|
||||
found.push(...findings.map((finding) => ({ diagram, index, ...finding })))
|
||||
}
|
||||
|
||||
const known = KNOWN_PROBLEMS.flatMap((problem) => problem.findings).filter(
|
||||
(finding) => finding.diagram === diagram
|
||||
)
|
||||
const foundKeys = new Set(found.map(getKey))
|
||||
const knownKeys = new Set(known.map(getKey))
|
||||
expect({
|
||||
unexpected: found.filter((finding) => !knownKeys.has(getKey(finding))),
|
||||
noLongerFound: known.filter((finding) => !foundKeys.has(getKey(finding))),
|
||||
}).toEqual({ unexpected: [], noLongerFound: [] })
|
||||
})
|
||||
})
|
||||
})
|
||||
202
apps/examples/e2e/perf/test-perf.spec.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { createAnalyticsReporter, getTestContext } from '../fixtures/analytics-reporter'
|
||||
import test from '../fixtures/fixtures'
|
||||
import {
|
||||
formatPerformanceResults,
|
||||
PerformanceTestResult,
|
||||
setupPerformanceTest,
|
||||
} from '../fixtures/perf-utils'
|
||||
import { setup } from '../shared-e2e'
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
function testOutput(result: PerformanceTestResult) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`${result.interaction} Performance: ${result.metrics.averageFps} avg FPS (min: ${result.metrics.minFps}, max: ${result.metrics.maxFps})\n` +
|
||||
`${result.comparison.message}\n` +
|
||||
'Comparison:' +
|
||||
JSON.stringify(result.comparison, null, 2)
|
||||
)
|
||||
|
||||
// Validate performance
|
||||
expect(result.metrics.averageFps).toBeGreaterThan(18)
|
||||
expect(result.comparison.status).not.toBe('fail')
|
||||
|
||||
// Update baseline if this is a significant improvement
|
||||
if (result.comparison.isImprovement) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Consider updating baseline - performance improved!')
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Performance Tests', () => {
|
||||
test.beforeEach(setup)
|
||||
test.setTimeout(120000) // Increase timeout
|
||||
test.describe.configure({ mode: 'serial' }) // Run tests in series to avoid resource conflicts
|
||||
|
||||
test('Baseline FPS Performance - Desktop', async ({
|
||||
page,
|
||||
context,
|
||||
request,
|
||||
browserName,
|
||||
isMobile,
|
||||
}) => {
|
||||
if (isMobile) return
|
||||
|
||||
const perfSuite = await setupPerformanceTest({ page, context, request }, browserName)
|
||||
const analyticsReporter = createAnalyticsReporter()
|
||||
|
||||
// Run full performance test suite
|
||||
const results = await perfSuite.runFullPerformanceTest()
|
||||
|
||||
// Log detailed results
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(formatPerformanceResults(results))
|
||||
|
||||
// Send analytics
|
||||
const testContext = getTestContext('Baseline FPS Performance - Desktop')
|
||||
await analyticsReporter.reportPerformanceMetrics(results, testContext)
|
||||
|
||||
// Send alerts for any regressions
|
||||
for (const result of results.filter((r) => r.comparison.status === 'fail')) {
|
||||
await analyticsReporter.reportRegressionAlert(result, testContext)
|
||||
}
|
||||
|
||||
// Check for any failures
|
||||
const failedTests = results.filter((result) => result.comparison.status === 'fail')
|
||||
if (failedTests.length > 0) {
|
||||
const failureMessages = failedTests
|
||||
.map((t) => `${t.interaction}: ${t.comparison.message}`)
|
||||
.join('\n')
|
||||
expect(failedTests.length, `Performance tests failed:\n${failureMessages}`).toBe(0)
|
||||
}
|
||||
|
||||
// Warn about any significant changes
|
||||
const warningTests = results.filter((result) => result.comparison.status === 'warning')
|
||||
if (warningTests.length > 0) {
|
||||
console.warn('Performance warnings detected:')
|
||||
warningTests.forEach((t) => {
|
||||
console.warn(` ${t.interaction}: ${t.comparison.message}`)
|
||||
})
|
||||
}
|
||||
|
||||
// Assert overall test health
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
|
||||
// Ensure all tests have reasonable FPS values
|
||||
results.forEach((result) => {
|
||||
expect(result.metrics.averageFps).toBeGreaterThan(0)
|
||||
expect(result.metrics.averageFps).toBeLessThanOrEqual(60)
|
||||
})
|
||||
})
|
||||
|
||||
test('Shape Rotation Performance', async ({ page, context, request, browserName, isMobile }) => {
|
||||
if (isMobile) return
|
||||
|
||||
const perfSuite = await setupPerformanceTest({ page, context, request }, browserName)
|
||||
await perfSuite.setupHeavyBoard()
|
||||
|
||||
testOutput(await perfSuite.testShapeRotation())
|
||||
})
|
||||
|
||||
test('Shape Dragging Performance', async ({ page, context, request, browserName, isMobile }) => {
|
||||
if (isMobile) return
|
||||
|
||||
const perfSuite = await setupPerformanceTest({ page, context, request }, browserName)
|
||||
await perfSuite.setupHeavyBoard()
|
||||
|
||||
testOutput(await perfSuite.testShapeDragging())
|
||||
})
|
||||
|
||||
test('Shape Resizing Performance', async ({ page, context, request, browserName, isMobile }) => {
|
||||
if (isMobile) return
|
||||
|
||||
const perfSuite = await setupPerformanceTest({ page, context, request }, browserName)
|
||||
await perfSuite.setupHeavyBoard(50)
|
||||
|
||||
testOutput(await perfSuite.testShapeResizing())
|
||||
})
|
||||
|
||||
test('Canvas Panning Performance', async ({ page, context, request, browserName, isMobile }) => {
|
||||
if (isMobile) return
|
||||
|
||||
const perfSuite = await setupPerformanceTest({ page, context, request }, browserName)
|
||||
await perfSuite.setupHeavyBoard(1000)
|
||||
|
||||
testOutput(await perfSuite.testCanvasPanning())
|
||||
})
|
||||
|
||||
test('Canvas Zooming Performance', async ({ page, context, request, browserName, isMobile }) => {
|
||||
if (isMobile) return
|
||||
|
||||
const perfSuite = await setupPerformanceTest({ page, context, request }, browserName)
|
||||
await perfSuite.setupHeavyBoard()
|
||||
|
||||
testOutput(await perfSuite.testCanvasZooming())
|
||||
})
|
||||
|
||||
test('Text Shape Creation Performance', async ({
|
||||
page,
|
||||
context,
|
||||
request,
|
||||
browserName,
|
||||
isMobile,
|
||||
}) => {
|
||||
if (isMobile) return
|
||||
|
||||
// The scenario creates its own text shapes, so no heavy board is needed.
|
||||
const perfSuite = await setupPerformanceTest({ page, context, request }, browserName)
|
||||
|
||||
testOutput(await perfSuite.testTextShapeCreation())
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Baseline Management', () => {
|
||||
test.skip('Establish All Performance Baselines', async ({
|
||||
page,
|
||||
context,
|
||||
request,
|
||||
browserName,
|
||||
}) => {
|
||||
test.setTimeout(120000) // 2 minutes for establishing all baselines
|
||||
|
||||
const perfSuite = await setupPerformanceTest({ page, context, request }, browserName)
|
||||
|
||||
// This will run all performance tests and establish baselines
|
||||
await perfSuite.establishBaselines()
|
||||
|
||||
// Verify baselines were created
|
||||
const rotateBaseline = perfSuite.getBaseline('rotate_shapes')
|
||||
const dragBaseline = perfSuite.getBaseline('drag_shapes')
|
||||
const resizeBaseline = perfSuite.getBaseline('resize_shapes')
|
||||
const panBaseline = perfSuite.getBaseline('canvas_panning')
|
||||
const zoomBaseline = perfSuite.getBaseline('canvas_zooming')
|
||||
|
||||
expect(rotateBaseline).toBeTruthy()
|
||||
expect(dragBaseline).toBeTruthy()
|
||||
expect(resizeBaseline).toBeTruthy()
|
||||
expect(panBaseline).toBeTruthy()
|
||||
expect(zoomBaseline).toBeTruthy()
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('✅ All baselines established successfully!')
|
||||
})
|
||||
|
||||
test.skip('Manual Baseline Update Example', async ({ page, context, request, browserName }) => {
|
||||
const perfSuite = await setupPerformanceTest({ page, context, request }, browserName)
|
||||
await perfSuite.setupHeavyBoard()
|
||||
|
||||
const result = await perfSuite.testShapeRotation()
|
||||
|
||||
// Example of manually updating baseline (use with caution)
|
||||
const existingBaseline = perfSuite.getBaseline('rotate_shapes')
|
||||
if (!existingBaseline || result.metrics.averageFps > existingBaseline.avgFps) {
|
||||
perfSuite.updateBaseline('rotate_shapes', result.metrics, true)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`📊 Baseline updated: ${result.metrics.averageFps} FPS`)
|
||||
}
|
||||
|
||||
expect(result.comparison).toBeDefined()
|
||||
})
|
||||
})
|
||||
122
apps/examples/e2e/playwright.config.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import path from 'path'
|
||||
import { devices } from '@playwright/test'
|
||||
import type { PlaywrightTestConfig } from '@playwright/test'
|
||||
import { config as _config } from 'dotenv'
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
_config()
|
||||
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname)
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
const config: PlaywrightTestConfig = {
|
||||
globalSetup: './global-setup.ts',
|
||||
globalTeardown: './global-teardown.ts',
|
||||
/* Maximum time one test can run for. */
|
||||
timeout: 30 * 1000,
|
||||
expect: {
|
||||
/**
|
||||
* Maximum time expect() should wait for the condition to be met.
|
||||
* For example in `await expect(locator).toHaveText();`
|
||||
*/
|
||||
timeout: 2000,
|
||||
toHaveScreenshot: {
|
||||
maxDiffPixelRatio: 0.0001,
|
||||
threshold: 0.01,
|
||||
},
|
||||
},
|
||||
// Run tests fully in parallel for faster execution.
|
||||
// Clipboard tests are already skipped due to flakiness.
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false, // !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: process.env.CI ? [['list'], ['github'], ['html', { open: 'never' }]] : 'list',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||
actionTimeout: 0,
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://localhost:5420',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
headless: true, // !process.env.CI,
|
||||
video: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
},
|
||||
|
||||
// {
|
||||
// name: 'webkit',
|
||||
// use: {
|
||||
// ...devices['Desktop Safari'],
|
||||
// },
|
||||
// },
|
||||
|
||||
// /* Test against mobile viewports. */
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: {
|
||||
...devices['Pixel 5'],
|
||||
},
|
||||
},
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: {
|
||||
// ...devices['iPhone 12'],
|
||||
// },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: {
|
||||
// channel: 'msedge',
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: {
|
||||
// channel: 'chrome',
|
||||
// },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||
outputDir: './test-results',
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: [
|
||||
{
|
||||
command: process.env.CI ? 'yarn preview' : 'yarn preview-examples',
|
||||
port: 5420,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
cwd: process.env.CI ? path.join(__dirname, '..') : path.join(__dirname, '../../..'),
|
||||
timeout: process.env.CI ? 30_000 : 300_000,
|
||||
},
|
||||
{
|
||||
command: 'yarn dev',
|
||||
port: 8989,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
cwd: path.join(__dirname, '../../../apps/bemo-worker'),
|
||||
timeout: 60_000,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default config
|
||||
163
apps/examples/e2e/shared-e2e.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
BrowserContext,
|
||||
Locator,
|
||||
Page,
|
||||
PlaywrightTestArgs,
|
||||
PlaywrightWorkerArgs,
|
||||
} from '@playwright/test'
|
||||
import { type Editor } from 'tldraw'
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export async function setup({ page, context }: PlaywrightTestArgs & PlaywrightWorkerArgs) {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await setupPage(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart setup that navigates on first run, then uses fast reset on subsequent runs.
|
||||
* Use this in beforeEach for optimal test performance with page reuse.
|
||||
*/
|
||||
export async function setupOrReset({ page, context }: { page: Page; context: BrowserContext }) {
|
||||
const url = page.url()
|
||||
if (!url.includes('end-to-end')) {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await setupPage(page)
|
||||
} else {
|
||||
await hardResetEditor(page)
|
||||
}
|
||||
}
|
||||
|
||||
export async function setupWithShapes({ page }: PlaywrightTestArgs & PlaywrightWorkerArgs) {
|
||||
await setupPage(page)
|
||||
await setupPageWithShapes(page)
|
||||
}
|
||||
|
||||
export async function cleanup({ page }: PlaywrightTestArgs) {
|
||||
await cleanupPage(page)
|
||||
}
|
||||
|
||||
export async function setupPage(page: PlaywrightTestArgs['page']) {
|
||||
await page.goto('http://localhost:5420/end-to-end')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
await page.evaluate(() => {
|
||||
editor.user.updateUserPreferences({ animationSpeed: 0 })
|
||||
})
|
||||
await page.mouse.move(50, 50)
|
||||
// Ensure the container has focus for keyboard events
|
||||
await page.locator('.tl-container').focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast reset of the editor state without page navigation.
|
||||
* Use this in beforeEach when the page is already set up via beforeAll.
|
||||
*/
|
||||
export async function hardResetEditor(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
// Clear all shapes and reset editor state
|
||||
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
|
||||
editor.setCurrentTool('select')
|
||||
editor.zoomToFit()
|
||||
editor.resetZoom()
|
||||
})
|
||||
await page.mouse.move(50, 50)
|
||||
// Ensure the container has focus for keyboard events
|
||||
await page.locator('.tl-container').focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast reset and create test shapes without page navigation.
|
||||
* Use this in beforeEach when tests need shapes but want to avoid full setup.
|
||||
*/
|
||||
export async function hardResetWithShapes(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
// Clear all shapes and create test shapes
|
||||
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
|
||||
editor.createShapes([
|
||||
{ type: 'geo', x: 200, y: 200, props: { w: 100, h: 100, geo: 'rectangle' } },
|
||||
{ type: 'geo', x: 200, y: 250, props: { w: 100, h: 100, geo: 'rectangle' } },
|
||||
{ type: 'geo', x: 250, y: 300, props: { w: 100, h: 100, geo: 'rectangle' } },
|
||||
])
|
||||
editor.selectNone()
|
||||
editor.setCurrentTool('select')
|
||||
})
|
||||
await page.mouse.move(50, 50)
|
||||
// Ensure the container has focus for keyboard events
|
||||
await page.locator('.tl-container').focus()
|
||||
}
|
||||
|
||||
export async function setupPageWithShapes(page: PlaywrightTestArgs['page']) {
|
||||
// delete everything
|
||||
await page.keyboard.press('Control+a')
|
||||
await page.keyboard.press('Backspace')
|
||||
|
||||
// create shapes
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(200, 200)
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(200, 250)
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(250, 300)
|
||||
// deselect everything
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
export async function cleanupPage(page: PlaywrightTestArgs['page']) {
|
||||
await page.keyboard.press('Control+a')
|
||||
await page.keyboard.press('Delete')
|
||||
}
|
||||
|
||||
export async function getAllShapeLabels(page: PlaywrightTestArgs['page']) {
|
||||
return await page
|
||||
.locator('.tl-shapes')
|
||||
.first()
|
||||
.evaluate((e) => {
|
||||
const labels: { index: string; label: string }[] = []
|
||||
for (const child of e.children) {
|
||||
const index = (child as HTMLDivElement).style.zIndex
|
||||
const label = child.querySelector('.tl-text-content') as HTMLDivElement
|
||||
labels.push({ index, label: label.innerText })
|
||||
}
|
||||
labels.sort((a, b) => (a.index > b.index ? 1 : -1))
|
||||
return labels.map((l) => l.label)
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAllShapeTypes(page: PlaywrightTestArgs['page']) {
|
||||
return await page
|
||||
.locator('.tl-shape')
|
||||
.elementHandles()
|
||||
.then((handles) => Promise.all(handles.map((h) => h.getAttribute('data-shape-type'))))
|
||||
}
|
||||
|
||||
export async function withMenu<T>(page: Page, path: string, cb: (item: Locator) => Promise<T>) {
|
||||
const parts = path.split('.')
|
||||
const lastPartIdx = parts.length - 1
|
||||
for (let i = 0; i < lastPartIdx; i++) {
|
||||
const part = parts[i]
|
||||
if (i === 0) {
|
||||
// context menu should already be open
|
||||
if (part !== 'context-menu') {
|
||||
await page.getByTestId(`${part}.button`).click()
|
||||
}
|
||||
} else {
|
||||
await page.getByTestId(`${parts[0]}-sub.${part}-button`).click()
|
||||
}
|
||||
}
|
||||
|
||||
// last part!
|
||||
return await cb(page.getByTestId(`${parts[0]}.${parts[lastPartIdx]}`))
|
||||
}
|
||||
export async function clickMenu(page: Page, path: string) {
|
||||
await withMenu(page, path, (item) => item.click())
|
||||
}
|
||||
|
||||
// We need a way to wait for the editor to finish a tick
|
||||
export function sleepFrames(frames = 2): Promise<void> {
|
||||
return sleep(frames * (1000 / 60))
|
||||
}
|
||||
38
apps/examples/e2e/tests/README-PERFORMANCE.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Performance Testing
|
||||
|
||||
FPS performance tests for tldraw to detect regressions and track improvements.
|
||||
These are tracked in https://eu.posthog.com/project/45972/insights/ZdpGyj1r
|
||||
|
||||
```bash
|
||||
# Run all performance tests
|
||||
yarn e2e-perf
|
||||
|
||||
# With UI
|
||||
yarn e2e-perf-ui
|
||||
```
|
||||
|
||||
Baselines are automatically created on first run.
|
||||
|
||||
## Regression Detection
|
||||
|
||||
- **Fail**: >10% performance drop
|
||||
- **Warning**: 5-10% change
|
||||
- **Pass**: Performance stable
|
||||
|
||||
Results compared against baselines in `baselines/fps-baselines.json`.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
When `PERFORMANCE_ANALYTICS_ENABLED` enabled, sends performance metrics and regression alerts to PostHog.
|
||||
|
||||
```bash
|
||||
# Analytics (optional)
|
||||
PERFORMANCE_ANALYTICS_ENABLED=true
|
||||
POSTHOG_PROJECT_KEY=your-key
|
||||
|
||||
# CI context
|
||||
GIT_COMMIT=abc123
|
||||
GIT_BRANCH=main
|
||||
```
|
||||
90
apps/examples/e2e/tests/export-mermaid-snapshots.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import assert from 'assert'
|
||||
import { rename, writeFile } from 'fs/promises'
|
||||
import { Page, expect } from '@playwright/test'
|
||||
import { Editor } from 'tldraw'
|
||||
import mermaidDefinitions from '../../src/examples/use-cases/hundred-mermaids/mermaids'
|
||||
import test, { ApiFixture } from '../fixtures/fixtures'
|
||||
import { hardResetEditor, setup } from '../shared-e2e'
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
interface MermaidSnapshotCase {
|
||||
name: string
|
||||
definition: string
|
||||
}
|
||||
|
||||
const snapshotCases: MermaidSnapshotCase[] = [
|
||||
{ name: 'flowchart decision', definition: mermaidDefinitions[0][0] },
|
||||
{ name: 'flowchart with subgraphs', definition: mermaidDefinitions[0][10] },
|
||||
{ name: 'flowchart with class defs', definition: mermaidDefinitions[0][15] },
|
||||
{ name: 'flowchart with custom edge styles', definition: mermaidDefinitions[0][17] },
|
||||
{ name: 'state diagram basic', definition: mermaidDefinitions[1][1] },
|
||||
{ name: 'state diagram with nested state', definition: mermaidDefinitions[1][8] },
|
||||
{ name: 'state diagram with fork join', definition: mermaidDefinitions[1][11] },
|
||||
{ name: 'sequence diagram basics', definition: mermaidDefinitions[2][0] },
|
||||
{ name: 'sequence diagram with alt', definition: mermaidDefinitions[2][10] },
|
||||
{ name: 'sequence diagram with parallel lanes', definition: mermaidDefinitions[2][12] },
|
||||
]
|
||||
|
||||
test.describe('Mermaid export snapshots', () => {
|
||||
test.skip(({ isMobile }) => isMobile, 'Mermaid export snapshots are desktop-only')
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('end-to-end')) {
|
||||
await setup({ page, context } as any)
|
||||
} else {
|
||||
await hardResetEditor(page)
|
||||
}
|
||||
})
|
||||
|
||||
for (const testCase of snapshotCases) {
|
||||
test(`${testCase.name} (light)`, async ({ page, api }) => {
|
||||
await page.evaluate(() => {
|
||||
editor.user.updateUserPreferences({ colorScheme: 'light' })
|
||||
editor
|
||||
.updateInstanceState({ exportBackground: true })
|
||||
.selectAll()
|
||||
.deleteShapes(editor.getSelectedShapeIds())
|
||||
})
|
||||
await api.resetMockShapeIds()
|
||||
await api.createMermaidDiagram(testCase.definition)
|
||||
await page.evaluate(() => {
|
||||
editor.selectAll()
|
||||
})
|
||||
|
||||
await snapshotTest(page, api)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
async function snapshotTest(page: Page, api: ApiFixture) {
|
||||
const downloadAndSnapshot = page.waitForEvent('download').then(async (download) => {
|
||||
const path = (await download.path()) as string
|
||||
assert(path)
|
||||
await rename(path, path + '.svg')
|
||||
await writeFile(
|
||||
path + '.html',
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<img src="${path}.svg" />
|
||||
`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
await page.goto(`file://${path}.html`)
|
||||
const clip = await page.$eval('img', (img) => img.getBoundingClientRect())
|
||||
await expect(page).toHaveScreenshot({
|
||||
omitBackground: true,
|
||||
clip,
|
||||
fullPage: true,
|
||||
// Rasterizing the exported SVG moves box outlines by a pixel now and then, about 0.2% of the
|
||||
// image (#10758). Mermaid layout is also checked by position in `e2e/mermaid-readability`.
|
||||
maxDiffPixelRatio: 0.003,
|
||||
})
|
||||
})
|
||||
await api.exportAsSvg()
|
||||
await downloadAndSnapshot
|
||||
}
|
||||
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 49 KiB |
582
apps/examples/e2e/tests/export-snapshots-data.tsx
Normal file
@@ -0,0 +1,582 @@
|
||||
/* eslint-disable react/no-string-refs, tldraw/no-internal-imports */
|
||||
import { b64Vecs, degreesToRadians, TLShapeCrop, toRichText, VecModel } from 'tldraw'
|
||||
import { TL } from 'tldraw/src/test/test-jsx'
|
||||
|
||||
const convexPoints: VecModel[] = [
|
||||
{ x: 0, y: 0, z: 0.5 },
|
||||
{ x: 0.1, y: 0, z: 0.5 },
|
||||
{ x: 0.47, y: 0, z: 0.5 },
|
||||
{ x: 1.61, y: 0, z: 0.5 },
|
||||
{ x: 3.9, y: 0, z: 0.5 },
|
||||
{ x: 7.09, y: 0, z: 0.5 },
|
||||
{ x: 10.66, y: 0, z: 0.5 },
|
||||
{ x: 14.15, y: 0, z: 0.5 },
|
||||
{ x: 17.3, y: 0, z: 0.5 },
|
||||
{ x: 20.28, y: 0, z: 0.5 },
|
||||
{ x: 23.43, y: 0.01, z: 0.5 },
|
||||
{ x: 26.21, y: 0.33, z: 0.5 },
|
||||
{ x: 28.78, y: 1.22, z: 0.5 },
|
||||
{ x: 31.52, y: 2.42, z: 0.5 },
|
||||
{ x: 34.2, y: 3.92, z: 0.5 },
|
||||
{ x: 36.52, y: 5.72, z: 0.5 },
|
||||
{ x: 38.36, y: 7.62, z: 0.5 },
|
||||
{ x: 40.26, y: 10.03, z: 0.5 },
|
||||
{ x: 42.18, y: 12.96, z: 0.5 },
|
||||
{ x: 44.04, y: 16.13, z: 0.5 },
|
||||
{ x: 45.89, y: 19.87, z: 0.5 },
|
||||
{ x: 47.5, y: 24.12, z: 0.5 },
|
||||
{ x: 48.77, y: 28.45, z: 0.5 },
|
||||
{ x: 49.75, y: 32.78, z: 0.5 },
|
||||
{ x: 50.36, y: 36.93, z: 0.5 },
|
||||
{ x: 50.72, y: 41.07, z: 0.5 },
|
||||
{ x: 50.84, y: 45.22, z: 0.5 },
|
||||
{ x: 50.84, y: 48.58, z: 0.5 },
|
||||
{ x: 50.84, y: 52.02, z: 0.5 },
|
||||
{ x: 50.79, y: 55.21, z: 0.5 },
|
||||
{ x: 50.45, y: 57.41, z: 0.5 },
|
||||
{ x: 49.83, y: 59.48, z: 0.5 },
|
||||
{ x: 49.09, y: 61.37, z: 0.5 },
|
||||
{ x: 48.22, y: 63.04, z: 0.5 },
|
||||
{ x: 47.28, y: 64.65, z: 0.5 },
|
||||
{ x: 46.22, y: 66.3, z: 0.5 },
|
||||
{ x: 45.03, y: 68.03, z: 0.5 },
|
||||
{ x: 43.81, y: 69.72, z: 0.5 },
|
||||
{ x: 42.48, y: 71.36, z: 0.5 },
|
||||
{ x: 40.85, y: 73.1, z: 0.5 },
|
||||
{ x: 39.24, y: 74.55, z: 0.5 },
|
||||
{ x: 37.4, y: 75.93, z: 0.5 },
|
||||
{ x: 35.5, y: 77.19, z: 0.5 },
|
||||
{ x: 33.64, y: 78.12, z: 0.5 },
|
||||
{ x: 31.66, y: 79.04, z: 0.5 },
|
||||
{ x: 29.82, y: 79.77, z: 0.5 },
|
||||
{ x: 27.93, y: 80.3, z: 0.5 },
|
||||
{ x: 25.85, y: 80.75, z: 0.5 },
|
||||
{ x: 23.56, y: 81.14, z: 0.5 },
|
||||
{ x: 21.35, y: 81.52, z: 0.5 },
|
||||
{ x: 19.18, y: 81.81, z: 0.5 },
|
||||
{ x: 17.08, y: 81.95, z: 0.5 },
|
||||
{ x: 15.18, y: 81.99, z: 0.5 },
|
||||
{ x: 13.43, y: 81.99, z: 0.5 },
|
||||
{ x: 11.91, y: 81.99, z: 0.5 },
|
||||
{ x: 10.63, y: 81.99, z: 0.5 },
|
||||
{ x: 9.47, y: 81.99, z: 0.5 },
|
||||
{ x: 8.43, y: 81.99, z: 0.5 },
|
||||
{ x: 7.57, y: 81.99, z: 0.5 },
|
||||
{ x: 6.81, y: 81.99, z: 0.5 },
|
||||
{ x: 6.05, y: 81.99, z: 0.5 },
|
||||
{ x: 5.31, y: 81.99, z: 0.5 },
|
||||
{ x: 4.67, y: 81.99, z: 0.5 },
|
||||
{ x: 4.17, y: 81.99, z: 0.5 },
|
||||
{ x: 3.71, y: 81.99, z: 0.5 },
|
||||
{ x: 3.26, y: 81.99, z: 0.5 },
|
||||
{ x: 2.85, y: 81.99, z: 0.5 },
|
||||
{ x: 2.46, y: 81.99, z: 0.5 },
|
||||
{ x: 2.13, y: 81.99, z: 0.5 },
|
||||
{ x: 1.77, y: 81.99, z: 0.5 },
|
||||
{ x: 1.37, y: 81.99, z: 0.5 },
|
||||
{ x: 0.96, y: 81.99, z: 0.5 },
|
||||
{ x: 0.46, y: 81.99, z: 0.5 },
|
||||
{ x: -0.03, y: 81.99, z: 0.5 },
|
||||
{ x: -0.37, y: 81.99, z: 0.5 },
|
||||
{ x: -0.62, y: 82.01, z: 0.5 },
|
||||
]
|
||||
|
||||
export const frameContent = (
|
||||
<>
|
||||
<TL.geo
|
||||
richText={toRichText('content')}
|
||||
w={100}
|
||||
h={100}
|
||||
x={50}
|
||||
y={50}
|
||||
rotation={degreesToRadians(35)}
|
||||
fill="solid"
|
||||
color="orange"
|
||||
/>
|
||||
<TL.arrow start={{ x: 50, y: 50 }} end={{ x: 50, y: 20 }} />
|
||||
</>
|
||||
)
|
||||
|
||||
export const manAsset = (
|
||||
<TL.asset.image
|
||||
w={100}
|
||||
h={200}
|
||||
src="/man.png"
|
||||
name="man"
|
||||
isAnimated={false}
|
||||
mimeType="image/png"
|
||||
/>
|
||||
)
|
||||
export const manCrop: TLShapeCrop = {
|
||||
topLeft: { x: 0.25, y: 0.05 },
|
||||
bottomRight: { x: 0.75, y: 0.3 },
|
||||
}
|
||||
export const manCropAsCircle: TLShapeCrop = {
|
||||
topLeft: { x: 0.25, y: 0.05 },
|
||||
bottomRight: { x: 0.75, y: 0.3 },
|
||||
isCircle: true,
|
||||
}
|
||||
export const richText = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { dir: 'auto', level: 3 },
|
||||
content: [{ type: 'text', text: 'Headers for things that wrap across multiple lines' }],
|
||||
},
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { dir: 'auto' },
|
||||
content: [{ type: 'text', text: 'testing 123' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { dir: 'auto' },
|
||||
content: [
|
||||
{ type: 'text', text: 'lists and ' },
|
||||
{
|
||||
type: 'text',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: 'https://tldraw.dev',
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
class: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
text: 'links',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { dir: 'auto' },
|
||||
content: [
|
||||
{ type: 'text', marks: [{ type: 'bold' }], text: 'and' },
|
||||
{ type: 'text', text: ' ' },
|
||||
{ type: 'text', marks: [{ type: 'strike' }], text: 'obvs' },
|
||||
{ type: 'text', text: ' ' },
|
||||
{ type: 'text', marks: [{ type: 'highlight' }], text: 'rich' },
|
||||
{ type: 'text', text: ' ' },
|
||||
{ type: 'text', marks: [{ type: 'code' }], text: 'text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { dir: 'auto' },
|
||||
content: [
|
||||
{ type: 'text', marks: [{ type: 'highlight' }], text: 'highlight ' },
|
||||
{ type: 'text', marks: [{ type: 'highlight' }, { type: 'bold' }], text: 'across' },
|
||||
{ type: 'text', marks: [{ type: 'highlight' }], text: ' styles' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const richTextForArrow = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { dir: 'auto', level: 3 },
|
||||
content: [{ type: 'text', text: 'Headers' }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { dir: 'auto' },
|
||||
content: [
|
||||
{ type: 'text', marks: [{ type: 'highlight' }], text: 'rich' },
|
||||
{ type: 'text', text: ' ' },
|
||||
{ type: 'text', marks: [{ type: 'code' }], text: 'text' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const convexDrawShape = (
|
||||
<TL.draw
|
||||
x={0.62}
|
||||
y={0}
|
||||
ref="convex"
|
||||
color="black"
|
||||
fill="none"
|
||||
isClosed={false}
|
||||
isComplete={true}
|
||||
isPen={false}
|
||||
size="m"
|
||||
segments={[
|
||||
{
|
||||
type: 'free',
|
||||
path: b64Vecs.encodePoints(convexPoints),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
export const heyDrawShape = (
|
||||
<TL.draw
|
||||
x={0}
|
||||
y={39.65841178508872}
|
||||
ref="hey"
|
||||
color="black"
|
||||
fill="none"
|
||||
isClosed={false}
|
||||
isComplete={true}
|
||||
isPen={false}
|
||||
size="m"
|
||||
segments={[
|
||||
{
|
||||
type: 'free',
|
||||
path: b64Vecs.encodePoints([
|
||||
{ x: 0, y: 0, z: 0.5 },
|
||||
{ x: 0.12, y: 0, z: 0.5 },
|
||||
{ x: 0.91, y: -0.18, z: 0.5 },
|
||||
{ x: 2.51, y: -0.94, z: 0.5 },
|
||||
{ x: 4.65, y: -2.47, z: 0.5 },
|
||||
{ x: 7.11, y: -4.78, z: 0.5 },
|
||||
{ x: 9.84, y: -7.98, z: 0.5 },
|
||||
{ x: 13.03, y: -12.61, z: 0.5 },
|
||||
{ x: 15.78, y: -17.73, z: 0.5 },
|
||||
{ x: 17.93, y: -22.87, z: 0.5 },
|
||||
{ x: 19.42, y: -27.3, z: 0.5 },
|
||||
{ x: 20.11, y: -30.11, z: 0.5 },
|
||||
{ x: 20.53, y: -32.62, z: 0.5 },
|
||||
{ x: 20.66, y: -35.23, z: 0.5 },
|
||||
{ x: 20.54, y: -36.97, z: 0.5 },
|
||||
{ x: 19.97, y: -38.12, z: 0.5 },
|
||||
{ x: 18.95, y: -39.25, z: 0.5 },
|
||||
{ x: 17.82, y: -39.85, z: 0.5 },
|
||||
{ x: 16.59, y: -40.07, z: 0.5 },
|
||||
{ x: 15.25, y: -40.08, z: 0.5 },
|
||||
{ x: 13.84, y: -39.7, z: 0.5 },
|
||||
{ x: 12.48, y: -38.72, z: 0.5 },
|
||||
{ x: 11.21, y: -37.3, z: 0.5 },
|
||||
{ x: 10.07, y: -35.5, z: 0.5 },
|
||||
{ x: 9.14, y: -33.38, z: 0.5 },
|
||||
{ x: 8.51, y: -30.99, z: 0.5 },
|
||||
{ x: 8.19, y: -28.34, z: 0.5 },
|
||||
{ x: 8.11, y: -25.55, z: 0.5 },
|
||||
{ x: 8.11, y: -22.75, z: 0.5 },
|
||||
{ x: 8.31, y: -20.23, z: 0.5 },
|
||||
{ x: 8.83, y: -17.85, z: 0.5 },
|
||||
{ x: 9.49, y: -15.27, z: 0.5 },
|
||||
{ x: 10.15, y: -12.53, z: 0.5 },
|
||||
{ x: 10.77, y: -9.56, z: 0.5 },
|
||||
{ x: 11.29, y: -6.5, z: 0.5 },
|
||||
{ x: 11.74, y: -3.53, z: 0.5 },
|
||||
{ x: 12.19, y: -0.31, z: 0.5 },
|
||||
{ x: 12.52, y: 2.88, z: 0.5 },
|
||||
{ x: 12.75, y: 5.9, z: 0.5 },
|
||||
{ x: 12.95, y: 8.95, z: 0.5 },
|
||||
{ x: 13.03, y: 11.73, z: 0.5 },
|
||||
{ x: 13.03, y: 14.14, z: 0.5 },
|
||||
{ x: 12.83, y: 16.09, z: 0.5 },
|
||||
{ x: 12.23, y: 17.74, z: 0.5 },
|
||||
{ x: 11.4, y: 19.09, z: 0.5 },
|
||||
{ x: 10.36, y: 20.15, z: 0.5 },
|
||||
{ x: 9.08, y: 20.99, z: 0.5 },
|
||||
{ x: 7.73, y: 21.59, z: 0.5 },
|
||||
{ x: 6.27, y: 21.97, z: 0.5 },
|
||||
{ x: 4.82, y: 22.08, z: 0.5 },
|
||||
{ x: 3.52, y: 21.99, z: 0.5 },
|
||||
{ x: 2.39, y: 21.44, z: 0.5 },
|
||||
{ x: 1.49, y: 20.34, z: 0.5 },
|
||||
{ x: 0.89, y: 18.96, z: 0.5 },
|
||||
{ x: 0.6, y: 17.5, z: 0.5 },
|
||||
{ x: 0.54, y: 16.08, z: 0.5 },
|
||||
{ x: 0.62, y: 14.79, z: 0.5 },
|
||||
{ x: 1.13, y: 13.53, z: 0.5 },
|
||||
{ x: 2.13, y: 12.22, z: 0.5 },
|
||||
{ x: 3.56, y: 10.8, z: 0.5 },
|
||||
{ x: 5.39, y: 9.18, z: 0.5 },
|
||||
{ x: 7.52, y: 7.49, z: 0.5 },
|
||||
{ x: 9.94, y: 5.75, z: 0.5 },
|
||||
{ x: 12.46, y: 3.98, z: 0.5 },
|
||||
{ x: 15.02, y: 2.32, z: 0.5 },
|
||||
{ x: 17.66, y: 0.54, z: 0.5 },
|
||||
{ x: 20.2, y: -1.35, z: 0.5 },
|
||||
{ x: 22.87, y: -3.45, z: 0.5 },
|
||||
{ x: 25.3, y: -5.53, z: 0.5 },
|
||||
{ x: 27.23, y: -7.49, z: 0.5 },
|
||||
{ x: 29.05, y: -9.64, z: 0.5 },
|
||||
{ x: 30.87, y: -12.22, z: 0.5 },
|
||||
{ x: 32.68, y: -15.35, z: 0.5 },
|
||||
{ x: 34.02, y: -18.4, z: 0.5 },
|
||||
{ x: 34.93, y: -21.39, z: 0.5 },
|
||||
{ x: 35.63, y: -24.49, z: 0.5 },
|
||||
{ x: 35.99, y: -27.13, z: 0.5 },
|
||||
{ x: 36.04, y: -28.96, z: 0.5 },
|
||||
{ x: 35.83, y: -30.34, z: 0.5 },
|
||||
{ x: 35.35, y: -31.44, z: 0.5 },
|
||||
{ x: 34.79, y: -32.04, z: 0.5 },
|
||||
{ x: 34.24, y: -32.33, z: 0.5 },
|
||||
{ x: 33.61, y: -32.41, z: 0.5 },
|
||||
{ x: 32.88, y: -32.36, z: 0.5 },
|
||||
{ x: 31.96, y: -31.95, z: 0.5 },
|
||||
{ x: 30.84, y: -30.99, z: 0.5 },
|
||||
{ x: 29.69, y: -29.69, z: 0.5 },
|
||||
{ x: 28.62, y: -28.16, z: 0.5 },
|
||||
{ x: 27.7, y: -26.41, z: 0.5 },
|
||||
{ x: 26.93, y: -24.43, z: 0.5 },
|
||||
{ x: 26.3, y: -22.19, z: 0.5 },
|
||||
{ x: 25.81, y: -19.62, z: 0.5 },
|
||||
{ x: 25.34, y: -16.39, z: 0.5 },
|
||||
{ x: 25.02, y: -13.18, z: 0.5 },
|
||||
{ x: 24.83, y: -9.63, z: 0.5 },
|
||||
{ x: 24.69, y: -6.01, z: 0.5 },
|
||||
{ x: 24.69, y: -3.02, z: 0.5 },
|
||||
{ x: 24.69, y: 0, z: 0.5 },
|
||||
{ x: 24.8, y: 2.74, z: 0.5 },
|
||||
{ x: 25.21, y: 5.11, z: 0.5 },
|
||||
{ x: 25.88, y: 7.15, z: 0.5 },
|
||||
{ x: 26.62, y: 8.87, z: 0.5 },
|
||||
{ x: 27.33, y: 10.2, z: 0.5 },
|
||||
{ x: 27.95, y: 11.12, z: 0.5 },
|
||||
{ x: 28.56, y: 11.81, z: 0.5 },
|
||||
{ x: 29.21, y: 12.34, z: 0.5 },
|
||||
{ x: 29.9, y: 12.72, z: 0.5 },
|
||||
{ x: 30.76, y: 13.03, z: 0.5 },
|
||||
{ x: 31.83, y: 13.23, z: 0.5 },
|
||||
{ x: 32.97, y: 13.28, z: 0.5 },
|
||||
{ x: 34.31, y: 13.22, z: 0.5 },
|
||||
{ x: 35.78, y: 12.84, z: 0.5 },
|
||||
{ x: 37.27, y: 12.05, z: 0.5 },
|
||||
{ x: 38.69, y: 11.06, z: 0.5 },
|
||||
{ x: 39.92, y: 9.99, z: 0.5 },
|
||||
{ x: 41.08, y: 8.71, z: 0.5 },
|
||||
{ x: 42.14, y: 7.13, z: 0.5 },
|
||||
{ x: 43.03, y: 5.41, z: 0.5 },
|
||||
{ x: 43.74, y: 3.61, z: 0.5 },
|
||||
{ x: 44.23, y: 1.73, z: 0.5 },
|
||||
{ x: 44.51, y: -0.23, z: 0.5 },
|
||||
{ x: 44.62, y: -2.13, z: 0.5 },
|
||||
{ x: 44.63, y: -3.61, z: 0.5 },
|
||||
{ x: 44.47, y: -4.88, z: 0.5 },
|
||||
{ x: 44.07, y: -5.88, z: 0.5 },
|
||||
{ x: 43.5, y: -6.42, z: 0.5 },
|
||||
{ x: 42.87, y: -6.81, z: 0.5 },
|
||||
{ x: 42.29, y: -7, z: 0.5 },
|
||||
{ x: 41.76, y: -7.06, z: 0.5 },
|
||||
{ x: 41.16, y: -6.99, z: 0.5 },
|
||||
{ x: 40.49, y: -6.64, z: 0.5 },
|
||||
{ x: 39.85, y: -5.97, z: 0.5 },
|
||||
{ x: 39.23, y: -5.13, z: 0.5 },
|
||||
{ x: 38.52, y: -4.11, z: 0.5 },
|
||||
{ x: 37.85, y: -3, z: 0.5 },
|
||||
{ x: 37.29, y: -2, z: 0.5 },
|
||||
{ x: 36.75, y: -1.06, z: 0.5 },
|
||||
{ x: 36.29, y: -0.14, z: 0.5 },
|
||||
{ x: 35.94, y: 0.72, z: 0.5 },
|
||||
{ x: 35.66, y: 1.75, z: 0.5 },
|
||||
{ x: 35.49, y: 2.84, z: 0.5 },
|
||||
{ x: 35.4, y: 3.93, z: 0.5 },
|
||||
{ x: 35.32, y: 5.19, z: 0.5 },
|
||||
{ x: 35.28, y: 6.54, z: 0.5 },
|
||||
{ x: 35.28, y: 7.9, z: 0.5 },
|
||||
{ x: 35.33, y: 9.08, z: 0.5 },
|
||||
{ x: 35.5, y: 10.03, z: 0.5 },
|
||||
{ x: 35.79, y: 10.87, z: 0.5 },
|
||||
{ x: 36.16, y: 11.56, z: 0.5 },
|
||||
{ x: 36.61, y: 12.13, z: 0.5 },
|
||||
{ x: 37.13, y: 12.61, z: 0.5 },
|
||||
{ x: 37.76, y: 13.1, z: 0.5 },
|
||||
{ x: 38.4, y: 13.58, z: 0.5 },
|
||||
{ x: 39.1, y: 13.96, z: 0.5 },
|
||||
{ x: 39.95, y: 14.36, z: 0.5 },
|
||||
{ x: 40.84, y: 14.69, z: 0.5 },
|
||||
{ x: 41.69, y: 14.88, z: 0.5 },
|
||||
{ x: 42.57, y: 15.02, z: 0.5 },
|
||||
{ x: 43.6, y: 15.1, z: 0.5 },
|
||||
{ x: 44.69, y: 15.08, z: 0.5 },
|
||||
{ x: 45.75, y: 14.8, z: 0.5 },
|
||||
{ x: 46.84, y: 14.18, z: 0.5 },
|
||||
{ x: 47.96, y: 13.35, z: 0.5 },
|
||||
{ x: 49.03, y: 12.36, z: 0.5 },
|
||||
{ x: 50, y: 11.23, z: 0.5 },
|
||||
{ x: 50.83, y: 9.93, z: 0.5 },
|
||||
{ x: 51.56, y: 8.45, z: 0.5 },
|
||||
{ x: 52.22, y: 6.81, z: 0.5 },
|
||||
{ x: 52.72, y: 5.09, z: 0.5 },
|
||||
{ x: 53.07, y: 3.35, z: 0.5 },
|
||||
{ x: 53.36, y: 1.6, z: 0.5 },
|
||||
{ x: 53.6, y: -0.35, z: 0.5 },
|
||||
{ x: 53.8, y: -2.37, z: 0.5 },
|
||||
{ x: 53.98, y: -4.06, z: 0.5 },
|
||||
{ x: 54.15, y: -5.39, z: 0.5 },
|
||||
{ x: 54.3, y: -6.4, z: 0.5 },
|
||||
{ x: 54.37, y: -6.95, z: 0.5 },
|
||||
{ x: 54.37, y: -7.18, z: 0.5 },
|
||||
{ x: 54.37, y: -7.21, z: 0.5 },
|
||||
{ x: 54.37, y: -7.12, z: 0.5 },
|
||||
{ x: 54.37, y: -6.92, z: 0.5 },
|
||||
{ x: 54.37, y: -6.43, z: 0.5 },
|
||||
{ x: 54.37, y: -5.71, z: 0.5 },
|
||||
{ x: 54.37, y: -4.8, z: 0.5 },
|
||||
{ x: 54.37, y: -3.66, z: 0.5 },
|
||||
{ x: 54.37, y: -2.34, z: 0.5 },
|
||||
{ x: 54.37, y: -0.89, z: 0.5 },
|
||||
{ x: 54.37, y: 0.58, z: 0.5 },
|
||||
{ x: 54.46, y: 2.04, z: 0.5 },
|
||||
{ x: 54.69, y: 3.49, z: 0.5 },
|
||||
{ x: 55.02, y: 4.89, z: 0.5 },
|
||||
{ x: 55.41, y: 6.29, z: 0.5 },
|
||||
{ x: 55.84, y: 7.62, z: 0.5 },
|
||||
{ x: 56.32, y: 8.89, z: 0.5 },
|
||||
{ x: 56.86, y: 10.2, z: 0.5 },
|
||||
{ x: 57.49, y: 11.48, z: 0.5 },
|
||||
{ x: 58.11, y: 12.51, z: 0.5 },
|
||||
{ x: 58.86, y: 13.46, z: 0.5 },
|
||||
{ x: 59.75, y: 14.33, z: 0.5 },
|
||||
{ x: 60.69, y: 14.83, z: 0.5 },
|
||||
{ x: 61.74, y: 15.08, z: 0.5 },
|
||||
{ x: 62.7, y: 15.21, z: 0.5 },
|
||||
{ x: 63.7, y: 15.24, z: 0.5 },
|
||||
{ x: 64.66, y: 15.06, z: 0.5 },
|
||||
{ x: 65.42, y: 14.5, z: 0.5 },
|
||||
{ x: 66.09, y: 13.69, z: 0.5 },
|
||||
{ x: 66.63, y: 12.77, z: 0.5 },
|
||||
{ x: 67.1, y: 11.64, z: 0.5 },
|
||||
{ x: 67.55, y: 10.06, z: 0.5 },
|
||||
{ x: 68.09, y: 8.03, z: 0.5 },
|
||||
{ x: 68.8, y: 5.7, z: 0.5 },
|
||||
{ x: 69.55, y: 3.22, z: 0.5 },
|
||||
{ x: 70.24, y: 0.97, z: 0.5 },
|
||||
{ x: 70.7, y: -0.61, z: 0.5 },
|
||||
{ x: 71, y: -1.73, z: 0.5 },
|
||||
{ x: 71.23, y: -2.55, z: 0.5 },
|
||||
{ x: 71.36, y: -2.95, z: 0.5 },
|
||||
{ x: 71.42, y: -3.12, z: 0.5 },
|
||||
{ x: 71.44, y: -3.17, z: 0.5 },
|
||||
{ x: 71.46, y: -3.15, z: 0.5 },
|
||||
{ x: 71.49, y: -2.95, z: 0.5 },
|
||||
{ x: 71.53, y: -2.34, z: 0.5 },
|
||||
{ x: 71.53, y: -1.18, z: 0.5 },
|
||||
{ x: 71.53, y: 0.66, z: 0.5 },
|
||||
{ x: 71.5, y: 2.95, z: 0.5 },
|
||||
{ x: 71.33, y: 5.58, z: 0.5 },
|
||||
{ x: 70.99, y: 8.57, z: 0.5 },
|
||||
{ x: 70.53, y: 11.96, z: 0.5 },
|
||||
{ x: 70.11, y: 15.29, z: 0.5 },
|
||||
{ x: 69.82, y: 18.28, z: 0.5 },
|
||||
{ x: 69.7, y: 21.19, z: 0.5 },
|
||||
{ x: 69.69, y: 23.9, z: 0.5 },
|
||||
{ x: 69.69, y: 26.33, z: 0.5 },
|
||||
{ x: 69.69, y: 28.61, z: 0.5 },
|
||||
{ x: 69.69, y: 30.96, z: 0.5 },
|
||||
{ x: 69.67, y: 33.31, z: 0.5 },
|
||||
{ x: 69.49, y: 35.59, z: 0.5 },
|
||||
{ x: 69.12, y: 37.74, z: 0.5 },
|
||||
{ x: 68.62, y: 39.65, z: 0.5 },
|
||||
{ x: 67.98, y: 41.3, z: 0.5 },
|
||||
{ x: 66.98, y: 42.84, z: 0.5 },
|
||||
{ x: 65.6, y: 44.27, z: 0.5 },
|
||||
{ x: 63.92, y: 45.56, z: 0.5 },
|
||||
{ x: 62.03, y: 46.71, z: 0.5 },
|
||||
{ x: 60.18, y: 47.41, z: 0.5 },
|
||||
{ x: 58.36, y: 47.69, z: 0.5 },
|
||||
{ x: 56.55, y: 47.57, z: 0.5 },
|
||||
{ x: 54.75, y: 46.87, z: 0.5 },
|
||||
{ x: 53.04, y: 45.75, z: 0.5 },
|
||||
{ x: 51.51, y: 44.61, z: 0.5 },
|
||||
{ x: 50.46, y: 43.69, z: 0.5 },
|
||||
{ x: 49.77, y: 42.93, z: 0.5 },
|
||||
{ x: 49.16, y: 42.03, z: 0.5 },
|
||||
{ x: 48.84, y: 41.13, z: 0.5 },
|
||||
{ x: 48.78, y: 40.33, z: 0.5 },
|
||||
{ x: 48.78, y: 39.54, z: 0.5 },
|
||||
{ x: 48.86, y: 38.75, z: 0.5 },
|
||||
{ x: 49.28, y: 38.01, z: 0.5 },
|
||||
{ x: 50.19, y: 37.36, z: 0.5 },
|
||||
{ x: 51.56, y: 36.7, z: 0.5 },
|
||||
{ x: 53.41, y: 36.07, z: 0.5 },
|
||||
{ x: 55.71, y: 35.44, z: 0.5 },
|
||||
{ x: 58.21, y: 34.85, z: 0.5 },
|
||||
{ x: 60.57, y: 34.32, z: 0.5 },
|
||||
{ x: 62.93, y: 33.75, z: 0.5 },
|
||||
{ x: 65.29, y: 33.12, z: 0.5 },
|
||||
{ x: 67.46, y: 32.5, z: 0.5 },
|
||||
{ x: 69.41, y: 31.8, z: 0.5 },
|
||||
{ x: 71.38, y: 30.83, z: 0.5 },
|
||||
{ x: 73.7, y: 29.37, z: 0.5 },
|
||||
{ x: 76.04, y: 27.45, z: 0.5 },
|
||||
{ x: 78.48, y: 24.96, z: 0.5 },
|
||||
{ x: 81.07, y: 21.89, z: 0.5 },
|
||||
{ x: 83.32, y: 18.78, z: 0.5 },
|
||||
{ x: 85.52, y: 15.01, z: 0.5 },
|
||||
{ x: 87.47, y: 10.56, z: 0.5 },
|
||||
{ x: 88.82, y: 5.98, z: 0.5 },
|
||||
{ x: 89.82, y: 1.05, z: 0.5 },
|
||||
{ x: 90.62, y: -4.02, z: 0.5 },
|
||||
{ x: 91.26, y: -9.09, z: 0.5 },
|
||||
{ x: 91.6, y: -14.06, z: 0.5 },
|
||||
{ x: 91.65, y: -19.16, z: 0.5 },
|
||||
{ x: 91.29, y: -24.1, z: 0.5 },
|
||||
{ x: 90.46, y: -27.87, z: 0.5 },
|
||||
{ x: 89.23, y: -30.92, z: 0.5 },
|
||||
{ x: 87.67, y: -33.59, z: 0.5 },
|
||||
{ x: 85.98, y: -35.5, z: 0.5 },
|
||||
{ x: 84.22, y: -36.87, z: 0.5 },
|
||||
{ x: 82.48, y: -37.85, z: 0.5 },
|
||||
{ x: 80.81, y: -38.35, z: 0.5 },
|
||||
{ x: 79.14, y: -38.5, z: 0.5 },
|
||||
{ x: 77.52, y: -38.52, z: 0.5 },
|
||||
{ x: 75.92, y: -38.28, z: 0.5 },
|
||||
{ x: 74.29, y: -37.58, z: 0.5 },
|
||||
{ x: 72.65, y: -36.53, z: 0.5 },
|
||||
{ x: 70.95, y: -35.18, z: 0.5 },
|
||||
{ x: 69.35, y: -33.68, z: 0.5 },
|
||||
{ x: 68.08, y: -32.31, z: 0.5 },
|
||||
{ x: 67.09, y: -31.05, z: 0.5 },
|
||||
{ x: 66.32, y: -29.84, z: 0.5 },
|
||||
{ x: 65.83, y: -28.72, z: 0.5 },
|
||||
{ x: 65.59, y: -27.68, z: 0.5 },
|
||||
{ x: 65.52, y: -26.71, z: 0.5 },
|
||||
{ x: 65.52, y: -25.66, z: 0.5 },
|
||||
{ x: 65.54, y: -24.54, z: 0.5 },
|
||||
{ x: 65.78, y: -23.6, z: 0.5 },
|
||||
{ x: 66.37, y: -22.77, z: 0.5 },
|
||||
{ x: 67.14, y: -22.04, z: 0.5 },
|
||||
{ x: 67.87, y: -21.52, z: 0.5 },
|
||||
{ x: 68.61, y: -21.2, z: 0.5 },
|
||||
{ x: 69.37, y: -21.04, z: 0.5 },
|
||||
{ x: 70.19, y: -21, z: 0.5 },
|
||||
{ x: 71.02, y: -21.2, z: 0.5 },
|
||||
{ x: 71.71, y: -21.75, z: 0.5 },
|
||||
{ x: 72.38, y: -22.5, z: 0.5 },
|
||||
{ x: 73.03, y: -23.28, z: 0.5 },
|
||||
{ x: 73.54, y: -24.03, z: 0.5 },
|
||||
{ x: 73.92, y: -24.68, z: 0.5 },
|
||||
{ x: 74.16, y: -25.17, z: 0.5 },
|
||||
{ x: 74.29, y: -25.47, z: 0.5 },
|
||||
{ x: 74.39, y: -25.67, z: 0.5 },
|
||||
{ x: 74.43, y: -25.8, z: 0.5 },
|
||||
{ x: 74.43, y: -25.88, z: 0.5 },
|
||||
{ x: 74.43, y: -25.96, z: 0.5 },
|
||||
{ x: 74.43, y: -26.06, z: 0.5 },
|
||||
{ x: 74.43, y: -26.16, z: 0.5 },
|
||||
{ x: 74.43, y: -26.26, z: 0.5 },
|
||||
{ x: 74.43, y: -26.34, z: 0.5 },
|
||||
{ x: 74.43, y: -26.4, z: 0.5 },
|
||||
]),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
1225
apps/examples/e2e/tests/export-snapshots.spec.tsx
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 97 KiB |
328
apps/examples/e2e/tests/test-a11y-style-panel-navigation.spec.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { Page, expect } from '@playwright/test'
|
||||
import { Editor } from 'tldraw'
|
||||
import test from '../fixtures/fixtures'
|
||||
import { setupPage } from '../shared-e2e'
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
test.describe('A11y Style Panel Navigation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupPage(page)
|
||||
})
|
||||
|
||||
test('can navigate between geo shape label editing and style panel with keyboard', async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
if (isMobile) {
|
||||
// Skip on mobile as the keyboard navigation behavior is different
|
||||
test.skip()
|
||||
}
|
||||
|
||||
// Create a geo shape
|
||||
await page.keyboard.press('r') // Rectangle tool
|
||||
await page.mouse.click(200, 200)
|
||||
await page.keyboard.press('v') // Select tool
|
||||
|
||||
// Verify we have a geo shape selected
|
||||
const selectedShapes = await page.evaluate(() => editor.getSelectedShapes())
|
||||
expect(selectedShapes).toHaveLength(1)
|
||||
expect(selectedShapes[0].type).toBe('geo')
|
||||
|
||||
// Step 1: Press Enter to start editing the label
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Verify we're in editing mode
|
||||
const isEditingShape = await page.evaluate(() => editor.getEditingShapeId() !== null)
|
||||
expect(isEditingShape).toBe(true)
|
||||
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
|
||||
|
||||
// Type some text
|
||||
await page.keyboard.type('Test Label')
|
||||
|
||||
// Step 2: Press Escape to exit editing mode
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// Verify we're no longer editing
|
||||
const isStillEditing = await page.evaluate(() => editor.getEditingShapeId() !== null)
|
||||
expect(isStillEditing).toBe(false)
|
||||
|
||||
// Step 3: Press Cmd+Enter to focus the style panel
|
||||
await page.keyboard.press('Meta+Enter')
|
||||
|
||||
// Verify that the first button in the style panel is focused
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const activeEl = document.activeElement
|
||||
return activeEl?.closest('.tlui-style-panel') !== null && activeEl?.tagName === 'BUTTON'
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// Step 4: Use arrow keys to navigate to a different color (assuming color buttons are first)
|
||||
await page.keyboard.press('ArrowRight')
|
||||
|
||||
// Verify focus has moved to the next button
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const activeEl = document.activeElement
|
||||
return activeEl?.tagName === 'BUTTON' && activeEl?.closest('.tlui-style-panel') !== null
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// Step 5: Press Enter to select the color
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Step 6: Press Escape to return focus to the shape. The Escape keydown
|
||||
// is what hands focus back to the canvas, and under load that single
|
||||
// keypress can be dropped, so re-send it until focus actually settles.
|
||||
await pressUntilFocusedOnCanvas(page)
|
||||
|
||||
// Step 7: Press Enter again to edit the label. Selecting a colour above makes
|
||||
// the style panel re-render, and its roving-tabindex toolbar can bounce focus
|
||||
// back onto the selected radio asynchronously — after Escape already returned
|
||||
// focus to the canvas. So drive editing through whatever focus state we land
|
||||
// in: Escape back to the canvas if focus bounced into the panel, otherwise
|
||||
// Enter to start editing. The label text is fully replaced below, so a stray
|
||||
// newline from a repeated Enter can't affect the result.
|
||||
await startEditingSelectedShapeViaKeyboard(page)
|
||||
|
||||
// The rich text editor focuses its contenteditable on a deferred timeout, so
|
||||
// wait for it to be the active element before typing or the first keystrokes
|
||||
// get dropped (matching the wait used when first entering editing above).
|
||||
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
|
||||
|
||||
// Replace the label text. Even once the contenteditable is focused, the
|
||||
// editor's async setup can still drop the leading keystrokes, so select-all,
|
||||
// type, and verify — retrying the whole select-and-replace until it sticks
|
||||
// rather than asserting a single racy attempt.
|
||||
await replaceSelectedShapeLabel(page, 'Updated Label')
|
||||
|
||||
// Exit editing
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
// Skipped: the cmd+enter keyboard event isn't reliably picked up while editing rich text in the
|
||||
// test harness, so the body passes intermittently. Because it was marked `test.fail`, those
|
||||
// intermittent passes were reported as failures and flaked the suite. The behavior works in
|
||||
// practice; re-enable once the event dispatch can be made deterministic (a reliable way to send
|
||||
// the shortcut to the focused contenteditable).
|
||||
test.skip('in a geo shape, cmd+enter returns focus to the canvas', async ({ page, isMobile }) => {
|
||||
if (isMobile) {
|
||||
test.skip()
|
||||
}
|
||||
|
||||
// Create a geo shape
|
||||
await page.keyboard.press('r') // Rectangle tool
|
||||
await page.mouse.click(200, 200)
|
||||
await page.keyboard.press('v') // Select tool
|
||||
|
||||
// press enter
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
|
||||
|
||||
// expect editor to be in the editing state
|
||||
expect(await page.evaluate(() => editor.isIn('select.editing_shape'))).toBe(true)
|
||||
|
||||
// dispatch cmd+enter to the active element
|
||||
await page.keyboard.press('ControlOrMeta+Enter')
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
expect(await page.evaluate(() => editor.isIn('select.editing_shape'))).toBe(false)
|
||||
|
||||
await expect.poll(() => isFocusedOnCanvas(page)).toBe(true)
|
||||
})
|
||||
|
||||
test('in a note shape, cmd+enter creates the next shape and does not focus on the style panel', async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
if (isMobile) {
|
||||
test.skip()
|
||||
}
|
||||
|
||||
// Create a note shape
|
||||
await page.keyboard.press('n') // Note tool
|
||||
await page.mouse.click(200, 200)
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
|
||||
|
||||
const firstShapeId = await page.evaluate(() => {
|
||||
return editor.getOnlySelectedShape()?.id
|
||||
})
|
||||
expect(firstShapeId).toBeDefined()
|
||||
|
||||
// Press cmd+enter
|
||||
await page.keyboard.press('Meta+Enter')
|
||||
|
||||
// Should be on a new shape now
|
||||
const finalShapeId = await page.evaluate(() => {
|
||||
return editor.getOnlySelectedShape()?.id
|
||||
})
|
||||
expect(finalShapeId).toBeDefined()
|
||||
expect(finalShapeId).not.toBe(firstShapeId)
|
||||
|
||||
await expect.poll(() => isFocusedOnStylePanel(page)).toBe(false)
|
||||
await expect.poll(() => isFocusedOnCanvas(page)).toBe(false)
|
||||
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
|
||||
|
||||
// Press escape
|
||||
await pressUntilFocusedOnCanvas(page)
|
||||
})
|
||||
|
||||
test('style panel focus works with different geo shapes', async ({ page, isMobile }) => {
|
||||
if (isMobile) {
|
||||
test.skip()
|
||||
}
|
||||
|
||||
// Test with ellipse
|
||||
await page.keyboard.press('o') // Ellipse tool
|
||||
await page.mouse.click(300, 300)
|
||||
await page.keyboard.press('v') // Select tool
|
||||
|
||||
// Verify we can focus style panel
|
||||
await page.keyboard.press('Meta+Enter')
|
||||
|
||||
await expect.poll(() => isFocusedOnStylePanel(page)).toBe(true)
|
||||
|
||||
// Return to shape and test editing
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
const isEditing = await page.evaluate(() => editor.getEditingShapeId() !== null)
|
||||
expect(isEditing).toBe(true)
|
||||
})
|
||||
|
||||
test('style panel focus works with cmd+enter shortcut', async ({ page, isMobile }) => {
|
||||
if (isMobile) {
|
||||
test.skip()
|
||||
}
|
||||
|
||||
// Create a geo shape
|
||||
await page.keyboard.press('r') // Rectangle tool
|
||||
await page.mouse.click(200, 200)
|
||||
await page.keyboard.press('v') // Select tool
|
||||
|
||||
await expect.poll(() => isFocusedOnCanvas(page)).toBe(true)
|
||||
|
||||
// Test that cmd+enter focuses the style panel even without editing first
|
||||
await page.keyboard.press('Meta+Enter')
|
||||
|
||||
await expect.poll(() => isFocusedOnStylePanel(page)).toBe(true)
|
||||
|
||||
// Navigate in the style panel
|
||||
await page.keyboard.press('ArrowRight')
|
||||
|
||||
// Verify focus is still in style panel
|
||||
await expect.poll(() => isFocusedOnStylePanel(page)).toBe(true)
|
||||
|
||||
// Return to canvas. The Escape keydown is what restores focus to the
|
||||
// canvas, and that single keypress can be dropped under load, so re-send
|
||||
// it until focus actually settles instead of asserting once.
|
||||
await pressUntilFocusedOnCanvas(page)
|
||||
})
|
||||
})
|
||||
|
||||
// Escape returns focus to the canvas from wherever it currently is — the style
|
||||
// panel (see DefaultStylePanel's keydown handler, which calls
|
||||
// editor.getContainer().focus() synchronously and stops propagation) or a shape's
|
||||
// rich text editor (Escape exits editing). The transition is instant, so when focus
|
||||
// fails to settle it's because the single Escape keypress was dropped under load
|
||||
// rather than because it was slow — so re-send it until focus returns to the canvas.
|
||||
// The early return means we never press while already on the canvas, so a stray
|
||||
// repeat can't deselect the shape.
|
||||
async function pressUntilFocusedOnCanvas(page: Page) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if (await isFocusedOnCanvas(page)) return true
|
||||
await page.keyboard.press('Escape')
|
||||
return isFocusedOnCanvas(page)
|
||||
},
|
||||
{ timeout: 5000, intervals: [100, 200, 300, 500] }
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
// Start editing the selected shape's label with the keyboard, tolerating both a
|
||||
// dropped keypress and the style panel's roving-tabindex focus bounce. Each round:
|
||||
// if focus has landed back in the style panel, Escape returns it to the canvas;
|
||||
// otherwise Enter starts editing the selected shape. We only press Enter while not
|
||||
// already editing, so we never inject a stray newline into an active label.
|
||||
async function startEditingSelectedShapeViaKeyboard(page: Page) {
|
||||
const isEditing = () => page.evaluate(() => editor.getEditingShapeId() !== null)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if (await isEditing()) return true
|
||||
const isInStylePanel = await page.evaluate(
|
||||
() => document.activeElement?.closest('.tlui-style-panel') !== null
|
||||
)
|
||||
await page.keyboard.press(isInStylePanel ? 'Escape' : 'Enter')
|
||||
return isEditing()
|
||||
},
|
||||
{ timeout: 8000, intervals: [100, 200, 300, 500] }
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
// Select all of the editing shape's label text and replace it, tolerating dropped
|
||||
// keystrokes during the editor's async setup. Each attempt re-selects everything
|
||||
// and retypes, so a partially-typed result from a previous attempt is fully
|
||||
// overwritten rather than appended to — the poll converges once nothing drops.
|
||||
// The select-all must be ControlOrMeta: plain Meta+a selects nothing on Linux, so
|
||||
// every retry would append another copy instead of replacing.
|
||||
async function replaceSelectedShapeLabel(page: Page, text: string) {
|
||||
const getText = () =>
|
||||
page.evaluate(() => {
|
||||
const shape = editor.getSelectedShapes()[0] as any
|
||||
return editor.getShapeUtil(shape).getText(shape)
|
||||
})
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await page.keyboard.press('ControlOrMeta+a')
|
||||
await page.keyboard.type(text)
|
||||
return getText()
|
||||
},
|
||||
{ timeout: 8000, intervals: [100, 200, 300, 500] }
|
||||
)
|
||||
.toBe(text)
|
||||
}
|
||||
|
||||
async function isFocusedOnStylePanel(page: Page) {
|
||||
return await page.evaluate(() => {
|
||||
const activeEl = document.activeElement
|
||||
return (
|
||||
// check that the active element is within both the editor container and the style panel
|
||||
activeEl?.role === 'radio' && activeEl?.closest('.tlui-style-panel') !== null
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function isFocusedOnRichTextEditor(page: Page) {
|
||||
return await page.evaluate(() => {
|
||||
const activeEl = document.activeElement
|
||||
return (
|
||||
activeEl?.role === 'textbox' &&
|
||||
activeEl?.closest('.tl-container') !== null &&
|
||||
activeEl?.closest('.tlui-style-panel') === null
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function isFocusedOnCanvas(page: Page) {
|
||||
return await page.evaluate(() => {
|
||||
const activeEl = document.activeElement
|
||||
return (
|
||||
activeEl?.role === 'application' &&
|
||||
activeEl?.closest('.tl-container') !== null &&
|
||||
activeEl?.closest('.tlui-style-panel') === null
|
||||
)
|
||||
})
|
||||
}
|
||||
20
apps/examples/e2e/tests/test-actions-menu.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import test from '../fixtures/fixtures'
|
||||
import { setupOrReset } from '../shared-e2e'
|
||||
|
||||
test.describe('actions menu', () => {
|
||||
test.beforeEach(setupOrReset)
|
||||
|
||||
test('you can open and close the actions menu', async ({ actionsMenu, menuClickCapture }) => {
|
||||
const { actionsMenuButton, actionsMenuContent } = actionsMenu
|
||||
await expect(actionsMenuContent).toBeHidden()
|
||||
await actionsMenuButton.click()
|
||||
await expect(actionsMenuContent).toBeVisible()
|
||||
await menuClickCapture.content.click()
|
||||
await expect(actionsMenuContent).toBeHidden()
|
||||
})
|
||||
|
||||
// ...
|
||||
// More tests here
|
||||
// ...
|
||||
})
|
||||
21
apps/examples/e2e/tests/test-api.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import test from '../fixtures/fixtures'
|
||||
import { hardResetWithShapes, setupWithShapes } from '../shared-e2e'
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('end-to-end')) {
|
||||
await setupWithShapes({ page, context } as any)
|
||||
} else {
|
||||
await hardResetWithShapes(page)
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('api', () => {
|
||||
for (const format of ['svg', 'png', 'jpeg', 'webp'] as const) {
|
||||
test(`export as ${format}`, async ({ page, api }) => {
|
||||
const downloadEvent = page.waitForEvent('download')
|
||||
await api.exportAsFormat(format)
|
||||
await downloadEvent
|
||||
})
|
||||
}
|
||||
})
|
||||
256
apps/examples/e2e/tests/test-camera.spec.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { CDPSession, Page, expect } from '@playwright/test'
|
||||
import { type Editor } from 'tldraw'
|
||||
import test from '../fixtures/fixtures'
|
||||
import { setupOrReset, sleep } from '../shared-e2e'
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
let client: CDPSession
|
||||
|
||||
const dispatchTouch = async (
|
||||
client: CDPSession,
|
||||
type: 'touchStart' | 'touchMove' | 'touchEnd' | 'touchCancel',
|
||||
touchPoints: { x: number; y: number }[]
|
||||
) => {
|
||||
await client.send('Input.dispatchTouchEvent', { type, touchPoints })
|
||||
}
|
||||
|
||||
const multiTouchGesture = async ({
|
||||
start,
|
||||
end,
|
||||
steps,
|
||||
}: {
|
||||
start: { x: number; y: number }[]
|
||||
end: { x: number; y: number }[]
|
||||
steps: number
|
||||
}) => {
|
||||
const finger1 = { x: start[0].x, y: start[0].y }
|
||||
const finger2 = { x: start[1].x, y: start[1].y }
|
||||
const finalTouch = [finger1, finger2]
|
||||
|
||||
await dispatchTouch(client, 'touchStart', [finger1, finger2])
|
||||
await sleep(10)
|
||||
|
||||
// how far should the fingers move each step to reach the end point?
|
||||
const finger1StepDistance = {
|
||||
x: (end[0].x - start[0].x) / steps,
|
||||
y: (end[0].y - start[0].y) / steps,
|
||||
}
|
||||
const finger2StepDistance = {
|
||||
x: (end[1].x - start[1].x) / steps,
|
||||
y: (end[1].y - start[1].y) / steps,
|
||||
}
|
||||
|
||||
for (let i = 1; i < steps; i++) {
|
||||
await dispatchTouch(client, 'touchMove', [
|
||||
{ x: finger1.x + i * finger1StepDistance.x, y: finger1.y + i * finger1StepDistance.y },
|
||||
{ x: finger2.x + i * finger2StepDistance.x, y: finger2.y + i * finger2StepDistance.y },
|
||||
])
|
||||
|
||||
await sleep(10)
|
||||
}
|
||||
|
||||
await dispatchTouch(client, 'touchEnd', finalTouch)
|
||||
await sleep(10)
|
||||
}
|
||||
|
||||
const scrollZoom = async ({
|
||||
page,
|
||||
currentZoomLevel,
|
||||
zoomDirection,
|
||||
steps,
|
||||
}: {
|
||||
page: Page
|
||||
currentZoomLevel: number
|
||||
zoomDirection: 'in' | 'out'
|
||||
steps: number
|
||||
}) => {
|
||||
page.keyboard.down('Control')
|
||||
// zooming with the scroll wheel is capped within the editor to make sure it's consistent between browsers,
|
||||
//scrolling further than 15px per event doesn't have any effect
|
||||
for (let i = 0; i < steps; i++) {
|
||||
await page.mouse.wheel(0, zoomDirection === 'out' ? -15 : 15)
|
||||
}
|
||||
page.keyboard.up('Control')
|
||||
// scrolling 15px on the mouse wheel changes the zoom level by 10%
|
||||
const expectedZoomLevel =
|
||||
zoomDirection === 'out' ? currentZoomLevel * 1.1 ** steps : currentZoomLevel / 1.1 ** steps
|
||||
|
||||
return expectedZoomLevel
|
||||
}
|
||||
|
||||
test.describe('camera', () => {
|
||||
test.beforeEach(setupOrReset)
|
||||
|
||||
test('panning', async ({ isMobile, page }) => {
|
||||
test.skip(!isMobile)
|
||||
client = await page.context().newCDPSession(page)
|
||||
expect(await page.evaluate(() => editor.inputs.getCurrentPagePoint())).toEqual({
|
||||
x: 50,
|
||||
y: 50,
|
||||
z: 0,
|
||||
})
|
||||
|
||||
// the fewer steps you use, the faster the pan. So the ratio of pixels moved is not one-to-one.
|
||||
await multiTouchGesture({
|
||||
start: [
|
||||
{ x: 50, y: 50 },
|
||||
{ x: 100, y: 100 },
|
||||
],
|
||||
end: [
|
||||
{ x: 100, y: 100 },
|
||||
{ x: 150, y: 150 },
|
||||
],
|
||||
steps: 50,
|
||||
})
|
||||
|
||||
// With current panning speed, the above gesture moves the camera by ~36px on each axis.
|
||||
// Use a tolerance to avoid brittleness across engines/emulation.
|
||||
const [x, y] = await page.evaluate(() => {
|
||||
const point = editor.inputs.getCurrentPagePoint()
|
||||
return [point.x, point.y]
|
||||
})
|
||||
expect(x).toBeGreaterThan(80)
|
||||
expect(x).toBeLessThan(95)
|
||||
expect(y).toBeGreaterThan(80)
|
||||
expect(y).toBeLessThan(95)
|
||||
})
|
||||
|
||||
test('pinching on trackpad', async ({ page, isMobile }) => {
|
||||
// Test is flaky, disabling.
|
||||
test.skip(true)
|
||||
|
||||
// pinching on trackpad is the same event as ctrl+scrollwheel
|
||||
test.skip(isMobile)
|
||||
expect(await page.evaluate(() => editor.getZoomLevel())).toBe(1)
|
||||
|
||||
let expectedZoomLevel = await scrollZoom({
|
||||
page,
|
||||
currentZoomLevel: 1,
|
||||
zoomDirection: 'in',
|
||||
steps: 3,
|
||||
})
|
||||
// toBeCloseTo is used to avoid floating point errors
|
||||
expect(await page.evaluate(() => editor.getZoomLevel())).toBeCloseTo(expectedZoomLevel, 1)
|
||||
|
||||
expectedZoomLevel = await scrollZoom({
|
||||
page,
|
||||
currentZoomLevel: expectedZoomLevel,
|
||||
zoomDirection: 'out',
|
||||
steps: 3,
|
||||
})
|
||||
|
||||
expect(await page.evaluate(() => editor.getZoomLevel())).toBeCloseTo(expectedZoomLevel, 1)
|
||||
})
|
||||
|
||||
test('pinching on touchscreen', async ({ page, isMobile }) => {
|
||||
test.skip(!isMobile)
|
||||
|
||||
client = await page.context().newCDPSession(page)
|
||||
|
||||
expect(await page.evaluate(() => editor.getZoomLevel())).toBe(1)
|
||||
|
||||
// zoom out
|
||||
await multiTouchGesture({
|
||||
start: [
|
||||
{ x: 100, y: 100 },
|
||||
{ x: 200, y: 200 },
|
||||
],
|
||||
end: [
|
||||
{ x: 149, y: 149 },
|
||||
{ x: 151, y: 151 },
|
||||
],
|
||||
steps: 50,
|
||||
})
|
||||
expect(await page.evaluate(() => editor.getZoomLevel())).toBe(0.05)
|
||||
|
||||
// now zoom in
|
||||
await multiTouchGesture({
|
||||
start: [
|
||||
{ x: 200, y: 200 },
|
||||
{ x: 200, y: 200 },
|
||||
],
|
||||
end: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 400, y: 400 },
|
||||
],
|
||||
steps: 50,
|
||||
})
|
||||
expect(await page.evaluate(() => editor.getZoomLevel())).toBe(8)
|
||||
})
|
||||
|
||||
test('two-finger pinch does not change the selection', async ({ page, isMobile }) => {
|
||||
test.skip(!isMobile)
|
||||
|
||||
client = await page.context().newCDPSession(page)
|
||||
|
||||
// Finger 1 lands here; finger 2 above it. Both are kept inside the mobile
|
||||
// viewport so the gesture registers.
|
||||
const f1 = { x: 196, y: 470, id: 0 }
|
||||
const f2 = { x: 196, y: 300, id: 1 }
|
||||
|
||||
// Shape "a" (selected, off to the side) plus shape "b" placed directly under
|
||||
// finger 1, so the first finger's pointer_down selects it.
|
||||
await page.evaluate((f) => {
|
||||
const p = editor.screenToPage({ x: f.x, y: f.y })
|
||||
editor.createShapes([
|
||||
{
|
||||
id: 'shape:e2eA',
|
||||
type: 'geo',
|
||||
x: -1000,
|
||||
y: -1000,
|
||||
props: { w: 100, h: 100, fill: 'solid' },
|
||||
},
|
||||
{
|
||||
id: 'shape:e2eB',
|
||||
type: 'geo',
|
||||
x: p.x - 60,
|
||||
y: p.y - 60,
|
||||
props: { w: 120, h: 120, fill: 'solid' },
|
||||
},
|
||||
] as any)
|
||||
editor.setSelectedShapes(['shape:e2eA'] as any)
|
||||
}, f1)
|
||||
|
||||
expect(await page.evaluate(() => editor.getSelectedShapeIds())).toEqual(['shape:e2eA'])
|
||||
|
||||
// Finger 1 down on shape b — through the real touch/pointer pipeline this
|
||||
// changes the selection to b.
|
||||
await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [f1] })
|
||||
await sleep(30)
|
||||
expect(await page.evaluate(() => editor.getSelectedShapeIds())).toEqual(['shape:e2eB'])
|
||||
|
||||
// Finger 2 joins, then both fingers move apart to pinch-zoom, then lift.
|
||||
await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [f1, f2] })
|
||||
await sleep(30)
|
||||
// The pinch has started, so the incidental selection is already rolled back —
|
||||
// the original selection is shown during the gesture, not only after it ends.
|
||||
expect(await page.evaluate(() => editor.getSelectedShapeIds())).toEqual(['shape:e2eA'])
|
||||
const steps = 12
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await client.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [
|
||||
{ x: f1.x, y: f1.y + i * 8, id: 0 }, // finger 1 moves down
|
||||
{ x: f2.x, y: f2.y - i * 8, id: 1 }, // finger 2 moves up
|
||||
],
|
||||
})
|
||||
await sleep(10)
|
||||
}
|
||||
await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
||||
await sleep(100)
|
||||
|
||||
// The incidental selection of b is rolled back to the pre-gesture selection.
|
||||
expect(await page.evaluate(() => editor.getSelectedShapeIds())).toEqual(['shape:e2eA'])
|
||||
// ...and the pinch really did zoom (sanity check that a pinch happened).
|
||||
expect(await page.evaluate(() => editor.getZoomLevel())).not.toBe(1)
|
||||
})
|
||||
|
||||
test.fixme('minimap', async () => {
|
||||
// todo
|
||||
})
|
||||
|
||||
test.fixme('hand tool', async () => {
|
||||
// todo
|
||||
})
|
||||
})
|
||||
153
apps/examples/e2e/tests/test-canvas-events.spec.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import test, { expect, Page } from '@playwright/test'
|
||||
import { Editor } from 'tldraw'
|
||||
import { setupPage } from '../shared-e2e'
|
||||
|
||||
declare const __tldraw_editor_events: any[]
|
||||
|
||||
// We're just testing the events, not the actual results.
|
||||
|
||||
let page: Page
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
test.describe('Canvas events', () => {
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage()
|
||||
await setupPage(page)
|
||||
})
|
||||
|
||||
test.describe('pointer events', () => {
|
||||
test('pointer down', async () => {
|
||||
await page.mouse.move(200, 200) // to kill any double clicks
|
||||
await page.mouse.move(100, 100)
|
||||
await page.mouse.down()
|
||||
await page.waitForTimeout(20)
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
target: 'canvas',
|
||||
type: 'pointer',
|
||||
name: 'pointer_down',
|
||||
})
|
||||
})
|
||||
|
||||
test('pointer move', async () => {
|
||||
await page.mouse.move(200, 200) // to kill any double clicks
|
||||
await page.mouse.move(100, 100)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(101, 101)
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
target: 'canvas',
|
||||
type: 'pointer',
|
||||
name: 'pointer_move',
|
||||
})
|
||||
})
|
||||
|
||||
test('pointer up', async () => {
|
||||
await page.mouse.move(200, 200) // to kill any double clicks
|
||||
await page.mouse.move(100, 100)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(101, 101)
|
||||
await page.mouse.up()
|
||||
await page.waitForTimeout(20)
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
target: 'canvas',
|
||||
type: 'pointer',
|
||||
name: 'pointer_up',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('click events', () => {
|
||||
// todo
|
||||
})
|
||||
|
||||
test.describe('pinch events', () => {
|
||||
// todo
|
||||
})
|
||||
|
||||
test.describe('keyboard events', () => {
|
||||
// todo
|
||||
})
|
||||
|
||||
test.fixme('wheel events', async () => {
|
||||
await page.mouse.move(200, 200) // to kill any double clicks
|
||||
await page.mouse.move(100, 100)
|
||||
await page.mouse.wheel(10, 10)
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
type: 'wheel',
|
||||
name: 'wheel',
|
||||
delta: {
|
||||
x: -20,
|
||||
y: -20,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.fixme('complete', async () => {
|
||||
await page.evaluate(async () => editor.complete())
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
type: 'misc',
|
||||
name: 'complete',
|
||||
})
|
||||
})
|
||||
|
||||
test.fixme('cancel', async () => {
|
||||
await page.evaluate(async () => editor.cancel())
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
type: 'misc',
|
||||
name: 'complete',
|
||||
})
|
||||
})
|
||||
|
||||
test.fixme('interrupt', async () => {
|
||||
await page.evaluate(async () => editor.interrupt())
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
type: 'misc',
|
||||
name: 'interrupt',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Shape events', () => {
|
||||
test.beforeEach(async ({ browser }) => {
|
||||
page = await browser.newPage()
|
||||
await setupPage(page)
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(150, 150)
|
||||
await page.mouse.move(0, 0)
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
test('pointer down', async () => {
|
||||
await page.mouse.move(51, 51)
|
||||
await page.mouse.down()
|
||||
await page.waitForTimeout(20)
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
target: 'canvas',
|
||||
type: 'pointer',
|
||||
name: 'pointer_down',
|
||||
})
|
||||
})
|
||||
|
||||
test('pointer move', async () => {
|
||||
await page.mouse.move(51, 51)
|
||||
await page.mouse.move(52, 52)
|
||||
await page.waitForTimeout(20)
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
target: 'canvas',
|
||||
type: 'pointer',
|
||||
name: 'pointer_move',
|
||||
})
|
||||
})
|
||||
|
||||
test('pointer up', async () => {
|
||||
await page.mouse.move(51, 51)
|
||||
await page.mouse.down()
|
||||
await page.mouse.up()
|
||||
await page.waitForTimeout(20)
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
target: 'canvas',
|
||||
type: 'pointer',
|
||||
name: 'pointer_up',
|
||||
})
|
||||
})
|
||||
})
|
||||
270
apps/examples/e2e/tests/test-clipboard-events.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { type Editor } from 'tldraw'
|
||||
import test from '../fixtures/fixtures'
|
||||
import { sleep } from '../shared-e2e'
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
test.describe('clipboard event callbacks', () => {
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await page.goto('http://localhost:5420/clipboard-events/full')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
await page.evaluate(() => {
|
||||
editor.user.updateUserPreferences({ animationSpeed: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
|
||||
;(window as any).__tldraw_clipboard_log = []
|
||||
;(window as any).__tldraw_clipboard_state.disableCopy = false
|
||||
;(window as any).__tldraw_clipboard_state.disablePaste = false
|
||||
;(window as any).__tldraw_clipboard_state.filterRedOnCopy = false
|
||||
;(window as any).__tldraw_clipboard_state.filterRedOnPaste = false
|
||||
;(window as any).__tldraw_clipboard_state.handleRawPaste = false
|
||||
})
|
||||
|
||||
await page.evaluate(() => {
|
||||
editor.createShapes([{ type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } }])
|
||||
editor.selectAll()
|
||||
})
|
||||
await page.locator('.tl-container').focus()
|
||||
})
|
||||
|
||||
test('onBeforeCopyToClipboard runs on keyboard copy', async ({ page, isMac }) => {
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyC')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(100)
|
||||
|
||||
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
|
||||
expect(log).toHaveLength(1)
|
||||
expect(log[0]).toMatchObject({ action: 'copy', source: 'native', prevented: false })
|
||||
})
|
||||
|
||||
test('onBeforeCopyToClipboard runs on keyboard cut', async ({ page, isMac }) => {
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyX')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(100)
|
||||
|
||||
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
|
||||
expect(log).toHaveLength(1)
|
||||
expect(log[0]).toMatchObject({ action: 'cut', source: 'native', prevented: false })
|
||||
})
|
||||
|
||||
test('onBeforePasteFromClipboard runs on keyboard paste', async ({ page, isMac }) => {
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyC')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(100)
|
||||
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyV')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(100)
|
||||
|
||||
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
|
||||
const pasteEntry = log.find((e: any) => e.action === 'paste')
|
||||
expect(pasteEntry).toMatchObject({
|
||||
action: 'paste',
|
||||
source: 'native-event',
|
||||
prevented: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('onBeforeCopyToClipboard can block copy', async ({ page, isMac }) => {
|
||||
await page.evaluate(() => {
|
||||
;(window as any).__tldraw_clipboard_state.disableCopy = true
|
||||
})
|
||||
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyC')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(100)
|
||||
|
||||
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
|
||||
expect(log).toHaveLength(1)
|
||||
expect(log[0]).toMatchObject({ action: 'copy', source: 'native', prevented: true })
|
||||
})
|
||||
|
||||
test('onBeforePasteFromClipboard can block paste', async ({ page, isMac }) => {
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
|
||||
// Copy normally first
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyC')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(100)
|
||||
|
||||
// Now block paste
|
||||
await page.evaluate(() => {
|
||||
;(window as any).__tldraw_clipboard_state.disablePaste = true
|
||||
;(window as any).__tldraw_clipboard_log = []
|
||||
})
|
||||
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyV')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(200)
|
||||
|
||||
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
|
||||
const pasteEntry = log.find((e: any) => e.action === 'paste')
|
||||
expect(pasteEntry).toMatchObject({
|
||||
action: 'paste',
|
||||
source: 'native-event',
|
||||
prevented: true,
|
||||
})
|
||||
|
||||
const shapeCount = await page.evaluate(() => editor.getCurrentPageShapes().length)
|
||||
expect(shapeCount).toBe(1)
|
||||
})
|
||||
|
||||
test('onBeforeCopyToClipboard can block cut', async ({ page, isMac }) => {
|
||||
await page.evaluate(() => {
|
||||
;(window as any).__tldraw_clipboard_state.disableCopy = true
|
||||
})
|
||||
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyX')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(100)
|
||||
|
||||
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
|
||||
expect(log).toHaveLength(1)
|
||||
expect(log[0]).toMatchObject({ action: 'cut', source: 'native', prevented: true })
|
||||
|
||||
const shapeCount = await page.evaluate(() => editor.getCurrentPageShapes().length)
|
||||
expect(shapeCount).toBe(1)
|
||||
})
|
||||
|
||||
test('onBeforeCopyToClipboard can filter shapes from copy', async ({ page, isMac }) => {
|
||||
// Create a red shape and a blue shape
|
||||
await page.evaluate(() => {
|
||||
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
|
||||
editor.createShapes([
|
||||
{ type: 'geo', x: 100, y: 100, props: { w: 100, h: 100, color: 'red' } },
|
||||
{ type: 'geo', x: 300, y: 100, props: { w: 100, h: 100, color: 'blue' } },
|
||||
])
|
||||
editor.selectAll()
|
||||
;(window as any).__tldraw_clipboard_log = []
|
||||
;(window as any).__tldraw_clipboard_state.filterRedOnCopy = true
|
||||
})
|
||||
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
|
||||
// Copy (should filter out the red shape)
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyC')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(200)
|
||||
|
||||
// Delete all shapes, then paste
|
||||
await page.evaluate(() => {
|
||||
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
|
||||
})
|
||||
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyV')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(200)
|
||||
|
||||
// Only the blue shape should have been pasted
|
||||
const shapes = await page.evaluate(() =>
|
||||
editor.getCurrentPageShapes().map((s: any) => s.props.color)
|
||||
)
|
||||
expect(shapes).toEqual(['blue'])
|
||||
|
||||
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
|
||||
const filterEntry = log.find((e: any) => e.action === 'filter-copy')
|
||||
expect(filterEntry).toMatchObject({
|
||||
action: 'filter-copy',
|
||||
detail: 'kept 1/2 shapes',
|
||||
})
|
||||
})
|
||||
|
||||
test('onBeforePasteFromClipboard can filter shapes on paste', async ({ page, isMac }) => {
|
||||
// Create a red shape and a blue shape, then copy them normally
|
||||
await page.evaluate(() => {
|
||||
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
|
||||
editor.createShapes([
|
||||
{ type: 'geo', x: 100, y: 100, props: { w: 100, h: 100, color: 'red' } },
|
||||
{ type: 'geo', x: 300, y: 100, props: { w: 100, h: 100, color: 'blue' } },
|
||||
])
|
||||
editor.selectAll()
|
||||
;(window as any).__tldraw_clipboard_log = []
|
||||
})
|
||||
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
|
||||
// Copy both shapes normally
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyC')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(200)
|
||||
|
||||
// Enable paste filter and delete all shapes
|
||||
await page.evaluate(() => {
|
||||
;(window as any).__tldraw_clipboard_state.filterRedOnPaste = true
|
||||
;(window as any).__tldraw_clipboard_log = []
|
||||
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
|
||||
})
|
||||
|
||||
// Paste (should filter out the red shape on the paste side)
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyV')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(200)
|
||||
|
||||
const shapes = await page.evaluate(() =>
|
||||
editor.getCurrentPageShapes().map((s: any) => s.props.color)
|
||||
)
|
||||
expect(shapes).toEqual(['blue'])
|
||||
|
||||
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
|
||||
const filterEntry = log.find((e: any) => e.action === 'filter-paste')
|
||||
expect(filterEntry).toMatchObject({
|
||||
action: 'filter-paste',
|
||||
detail: 'kept 1/2 shapes',
|
||||
})
|
||||
})
|
||||
|
||||
test('onClipboardPasteRaw runs before parsed paste and can take over (keyboard)', async ({
|
||||
page,
|
||||
isMac,
|
||||
}) => {
|
||||
await page.evaluate(() => {
|
||||
;(window as any).__tldraw_clipboard_state.handleRawPaste = true
|
||||
;(window as any).__tldraw_clipboard_log = []
|
||||
})
|
||||
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyC')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(100)
|
||||
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyV')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(200)
|
||||
|
||||
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
|
||||
const rawEntry = log.find((e: any) => e.action === 'raw-paste')
|
||||
expect(rawEntry).toMatchObject({ source: 'native-event' })
|
||||
expect(rawEntry.detail).toContain('string')
|
||||
|
||||
const parsedPaste = log.find((e: any) => e.action === 'paste' && e.source === 'native-event')
|
||||
expect(parsedPaste).toBeUndefined()
|
||||
|
||||
const shapeCount = await page.evaluate(() => editor.getCurrentPageShapes().length)
|
||||
expect(shapeCount).toBe(1)
|
||||
})
|
||||
})
|
||||
104
apps/examples/e2e/tests/test-clipboard.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { type Editor } from 'tldraw'
|
||||
import test from '../fixtures/fixtures'
|
||||
import { clickMenu, setup, sleep } from '../shared-e2e'
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
// these are skipped because they're flaky in CI :(
|
||||
test.describe.skip('clipboard tests', () => {
|
||||
test.beforeEach(setup)
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.move(100, 100)
|
||||
await page.mouse.down()
|
||||
await page.mouse.up()
|
||||
|
||||
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(1)
|
||||
expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1)
|
||||
})
|
||||
|
||||
test('copy and paste from keyboard shortcut', async ({ page, isMac }) => {
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyC')
|
||||
await sleep(100)
|
||||
await page.keyboard.press('KeyV')
|
||||
await page.keyboard.up(modifier)
|
||||
|
||||
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2)
|
||||
expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1)
|
||||
})
|
||||
|
||||
test('copy and paste from main menu', async ({ page }) => {
|
||||
await clickMenu(page, 'main-menu.edit.copy')
|
||||
await sleep(100)
|
||||
await clickMenu(page, 'main-menu.edit.paste')
|
||||
|
||||
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2)
|
||||
expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1)
|
||||
})
|
||||
|
||||
test('copy and paste from context menu', async ({ page }) => {
|
||||
await page.mouse.click(100, 100, { button: 'right' })
|
||||
await clickMenu(page, 'context-menu.copy')
|
||||
await sleep(100)
|
||||
await page.mouse.move(200, 200)
|
||||
await page.mouse.click(100, 100, { button: 'right' })
|
||||
await clickMenu(page, 'context-menu.paste')
|
||||
|
||||
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2)
|
||||
expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1)
|
||||
})
|
||||
|
||||
test('copy and paste png from context menu', async ({ page }) => {
|
||||
await page.mouse.click(100, 100, { button: 'right' })
|
||||
await clickMenu(page, 'context-menu.copy-as.copy-as-png')
|
||||
await sleep(100)
|
||||
|
||||
await page.mouse.move(400, 400)
|
||||
await page.mouse.click(100, 100, { button: 'right' })
|
||||
await clickMenu(page, 'context-menu.paste')
|
||||
await sleep(100)
|
||||
|
||||
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2)
|
||||
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
|
||||
type: 'image',
|
||||
props: { w: 264, h: 264 },
|
||||
})
|
||||
const imageWidth = await page
|
||||
.locator('.tl-image')
|
||||
.evaluate((img) => (img as HTMLImageElement).naturalWidth)
|
||||
expect(imageWidth).toBe(528)
|
||||
})
|
||||
|
||||
test('copy png with context menu, paste with keyboard', async ({ page, isMac }) => {
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
|
||||
await page.mouse.click(100, 100, { button: 'right' })
|
||||
await clickMenu(page, 'context-menu.copy-as.copy-as-png')
|
||||
await sleep(100)
|
||||
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyV')
|
||||
await sleep(100)
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(100)
|
||||
|
||||
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2)
|
||||
|
||||
// image should come in at the same size (200x200 + padding)
|
||||
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
|
||||
type: 'image',
|
||||
props: { w: 264, h: 264 },
|
||||
})
|
||||
|
||||
// but the actual image should be 2x that for retina displays
|
||||
const imageWidth = await page
|
||||
.locator('.tl-image')
|
||||
.evaluate((img) => (img as HTMLImageElement).naturalWidth)
|
||||
expect(imageWidth).toBe(528)
|
||||
})
|
||||
})
|
||||
219
apps/examples/e2e/tests/test-commenting-a11y.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { Page, expect } from '@playwright/test'
|
||||
import test from '../fixtures/fixtures'
|
||||
|
||||
// Pick the comment tool and click the canvas, then wait for the composer to take focus before
|
||||
// returning. Its autofocus runs on the next animation frame, so typing straight after the click
|
||||
// can land before it and be dropped — no text, nothing to post, no pin.
|
||||
async function startComment(page: Page) {
|
||||
await page.getByTestId('quick-actions.comment').click()
|
||||
await page.mouse.click(400, 300)
|
||||
await expect(
|
||||
page.locator('.tlui-cmt-canvas-composer [contenteditable="true"].tlui-cmt-input')
|
||||
).toBeFocused()
|
||||
}
|
||||
|
||||
// Place a comment at the same point, post it, and dismiss the thread the send leaves open.
|
||||
async function placeComment(page: Page, text: string) {
|
||||
await startComment(page)
|
||||
await page.keyboard.type(text)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas comment markers are the only way into a thread, so they have to be reachable without a
|
||||
* pointer. These tests drive the commenting example with the keyboard alone — place a comment with
|
||||
* the mouse (creating one is a canvas gesture), then never touch the mouse again.
|
||||
*/
|
||||
test.describe('commenting a11y', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('http://localhost:5420/commenting/full')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
await page.locator('.tl-container').focus()
|
||||
})
|
||||
|
||||
test('a comment pin is a labelled button that opens its thread from the keyboard', async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
// Keyboard navigation isn't the mobile interaction model.
|
||||
if (isMobile) test.skip()
|
||||
|
||||
// Place a comment: pick the comment tool, click the canvas, type, and send.
|
||||
const commentTool = page.getByTestId('quick-actions.comment')
|
||||
await commentTool.click()
|
||||
await expect(commentTool).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(commentTool).toHaveAttribute('data-isactive', 'true')
|
||||
await page.mouse.click(400, 300)
|
||||
await expect(
|
||||
page.locator('.tlui-cmt-canvas-composer [contenteditable="true"].tlui-cmt-input')
|
||||
).toBeFocused()
|
||||
await page.keyboard.type('hello from the keyboard')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// The pin exists and exposes button semantics with a name, not a bare div.
|
||||
const pin = page.locator('.tlui-cmt-canvas-pin__marker')
|
||||
await expect(pin).toHaveCount(1)
|
||||
await expect(pin).toHaveRole('button')
|
||||
await expect(pin).toHaveAttribute('aria-label', /comment by/i)
|
||||
|
||||
// Dismiss the thread the send left open, so the pin starts closed.
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(pin).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
// Focusing the pin and pressing Enter opens the thread — no pointer involved.
|
||||
await pin.focus()
|
||||
await expect(pin).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(pin).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(page.locator('.tlui-cmt-canvas-popover')).toBeVisible()
|
||||
|
||||
// Space toggles it back closed, as a button should.
|
||||
await pin.focus()
|
||||
await page.keyboard.press(' ')
|
||||
await expect(pin).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
test('the pin is reachable by tabbing, and the reply box has an accessible name', async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
if (isMobile) test.skip()
|
||||
|
||||
await startComment(page)
|
||||
|
||||
// The composer is a contenteditable whose visible placeholder is aria-hidden, so without an
|
||||
// explicit name a screen reader lands on an unlabelled textbox.
|
||||
const composer = page.locator('.tlui-cmt-input').first()
|
||||
await expect(composer).toHaveAttribute('aria-label', /.+/)
|
||||
await expect(composer).toHaveRole('textbox')
|
||||
|
||||
await page.keyboard.type('first')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
const pin = page.locator('.tlui-cmt-canvas-pin__marker')
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// The pin participates in the tab order rather than being skipped as a div would be.
|
||||
await page.locator('.tl-container').focus()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
await page.keyboard.press('Tab')
|
||||
if (await pin.evaluate((el) => el === document.activeElement)) return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('a pin comes after the UI, so "move focus to canvas" keeps the first tab stop', async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
if (isMobile) test.skip()
|
||||
|
||||
await placeComment(page, 'first')
|
||||
await expect(page.locator('.tlui-cmt-canvas-pin__marker')).toHaveCount(1)
|
||||
|
||||
// The comments layer is portalled into the editor container, and a pin is a real button. Left
|
||||
// where React drops the portal it lands ahead of the UI, taking the first tab stop from the
|
||||
// skip link — which is then unreachable, and with it the keyboard route onto the canvas.
|
||||
const tabOrder = await page.evaluate(() => {
|
||||
const focusable = document.querySelectorAll(
|
||||
'a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"]),[contenteditable="true"]'
|
||||
)
|
||||
return {
|
||||
firstIsSkipLink: focusable[0]?.classList.contains('tl-skip-to-main-content') ?? false,
|
||||
pinIndex: [...focusable].findIndex((el) =>
|
||||
el.classList.contains('tlui-cmt-canvas-pin__marker')
|
||||
),
|
||||
count: focusable.length,
|
||||
}
|
||||
})
|
||||
expect(tabOrder.firstIsSkipLink).toBe(true)
|
||||
expect(tabOrder.pinIndex).toBe(tabOrder.count - 1)
|
||||
|
||||
// And tabbing into the container really does land on the skip link first.
|
||||
await page.locator('.tl-container').focus()
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.locator('.tl-skip-to-main-content')).toBeFocused()
|
||||
})
|
||||
|
||||
test('leaving the edit composer hands focus back to the control that opened it', async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
if (isMobile) test.skip()
|
||||
|
||||
await placeComment(page, 'first')
|
||||
|
||||
// Open the thread and spend its one Tab-into-the-reply-box, so nothing is left to pull focus
|
||||
// back into the panel on its own — the state the escape has to survive.
|
||||
await page.locator('.tlui-cmt-canvas-pin__marker').click()
|
||||
await page.locator('.tl-container').focus()
|
||||
await page.keyboard.press('Tab')
|
||||
const reply = page.locator('.tlui-cmt-canvas-popover [contenteditable="true"].tlui-cmt-input')
|
||||
await expect(reply).toBeFocused()
|
||||
|
||||
// Edit from the ⋯ menu: escaping should land back on the ⋯ button that opened it, not on the
|
||||
// editor container, where Tab would walk the app's UI instead of the thread's own controls.
|
||||
const moreButton = page.locator('.tlui-cmt-canvas-popover [data-cmt-more-for]')
|
||||
await moreButton.click()
|
||||
await page.locator('.tlui-cmt-menu-item', { hasText: 'Edit' }).click()
|
||||
await expect(page.locator('.tlui-cmt-editing [contenteditable="true"]')).toBeFocused()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.locator('.tlui-cmt-canvas-popover')).toBeVisible()
|
||||
await expect(moreButton).toBeFocused()
|
||||
|
||||
// Arrow-up-to-edit comes from the reply box, so that's where escaping returns.
|
||||
await reply.focus()
|
||||
await page.keyboard.press('ArrowUp')
|
||||
await expect(page.locator('.tlui-cmt-editing [contenteditable="true"]')).toBeFocused()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.locator('.tlui-cmt-canvas-popover')).toBeVisible()
|
||||
await expect(reply).toBeFocused()
|
||||
})
|
||||
|
||||
test('a stacked card is keyboard-openable without nesting its body links in a button', async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
if (isMobile) test.skip()
|
||||
|
||||
// Two comments on the same point stack behind one badge. The first carries a link, which a
|
||||
// comment body renders as a real anchor.
|
||||
await placeComment(page, 'see https://tldraw.dev ')
|
||||
await placeComment(page, 'second')
|
||||
|
||||
// Open the stack list from the keyboard.
|
||||
const badge = page.locator('.tlui-cmt-canvas-stack-badge')
|
||||
await expect(badge).toHaveCount(1)
|
||||
await badge.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
const card = page.locator('.tlui-cmt-stack-list__card').first()
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card.locator('a[href]')).toHaveCount(1)
|
||||
|
||||
// A button can't contain interactive content, so the card's link must not sit inside one —
|
||||
// otherwise activation is unreliable and the control tree assistive tech sees is broken.
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
![...document.querySelectorAll('.tlui-cmt-stack-list__card a[href]')].some((a) =>
|
||||
a.closest('button')
|
||||
)
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
// The card is still openable by keyboard, through a named button covering its surface.
|
||||
const action = card.locator('.tlui-cmt-stack-list__card-action')
|
||||
await expect(action).toHaveAttribute('aria-label', /comment by/i)
|
||||
await action.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.locator('.tlui-cmt-stack-list__thread')).toHaveCount(1)
|
||||
})
|
||||
})
|
||||