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

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

View 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"
}
}

View 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',
}
}

View 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,
}
}
}

View 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

View 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
})
}
}

View 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 }
)
}
}

View 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'),
}
}
}

View 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')
}
}

View 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'),
],
}
}
}

View 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')
}
}

View 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'),
}
}
}

View 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 })
}
}

View 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 }
})
}
}

View 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')
}
}

View 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)
}
}

View 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
}

View File

@@ -0,0 +1,7 @@
import { FullConfig } from '@playwright/test'
async function globalSetup(config: FullConfig) {
return config
}
export default globalSetup

View File

@@ -0,0 +1,8 @@
import { FullConfig } from '@playwright/test'
async function globalTeardown(config: FullConfig) {
// noop
return config
}
export default globalTeardown

View File

@@ -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: [] })
})
})
})

View 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()
})
})

View 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

View 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))
}

View 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
```

View 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
}

View 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 },
]),
},
]}
/>
)

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View 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
)
})
}

View 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
// ...
})

View 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
})
}
})

View 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
})
})

View 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',
})
})
})

View 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)
})
})

View 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)
})
})

View 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)
})
})

View File

@@ -0,0 +1,298 @@
import test, { Page, expect } from '@playwright/test'
import { hardResetEditor, setupPage, setupPageWithShapes, withMenu } from '../shared-e2e'
declare const __tldraw_ui_event: { name: string }
// `editor` is a global exposed on the page, not imported at test-runner level.
// Importing from 'tldraw' (even as `type`) breaks Node's --no-strip-types because
// the tldraw package re-exports PointerEvent from react, which doesn't exist in Node.
declare const editor: {
inputs: any
getCamera(): { x: number; y: number; z: number }
getCurrentToolId(): string
getPath(): string
getSelectedShapeIds(): string[]
}
// We're just testing the events, not the actual results.
let page: Page
// Mobile emulation in Playwright dispatches touch events for `mouse.click/down/up`,
// so it can't simulate a real right-mouse-button sequence. Tests that depend on
// right-click pointer events only run on desktop (Chromium).
const isMobileProject = () => test.info().project.name.includes('Mobile')
test.describe('Context menu', async () => {
test.beforeEach(async ({ browser }) => {
if (!page) {
page = await browser.newPage()
await setupPage(page)
} else {
await hardResetEditor(page)
}
await setupPageWithShapes(page)
})
test('distribute horizontal', async () => {
// distribute horizontal
await page.keyboard.press('Control+a')
await page.mouse.click(200, 200, { button: 'right' })
await withMenu(page, 'context-menu.arrange.distribute-horizontal', (item) => item.focus())
await page.keyboard.press('Enter')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'distribute-shapes',
data: { operation: 'horizontal', source: 'context-menu' },
})
})
test('distribute vertical', async () => {
// distribute vertical — Shift+Alt+V
await page.keyboard.press('Control+a')
await page.mouse.click(200, 200, { button: 'right' })
await withMenu(page, 'context-menu.arrange.distribute-vertical', (item) => item.focus())
await page.keyboard.press('Enter')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'distribute-shapes',
data: { operation: 'vertical', source: 'context-menu' },
})
})
test('reopens after moving and right-clicking again', async () => {
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
await page.mouse.click(200, 200, { button: 'right' })
await expect(page.getByTestId('context-menu')).toBeVisible()
await page.mouse.move(600, 400)
await page.mouse.down({ button: 'right' })
await page.mouse.up({ button: 'right' })
await expect(page.getByTestId('context-menu')).toBeVisible()
expect(
await page.evaluate(() => ({
isRightPointing: editor.inputs.getIsRightPointing(),
isPanning: editor.inputs.getIsPanning(),
}))
).toMatchObject({
isRightPointing: false,
isPanning: false,
})
})
test('static right-click on plain canvas opens context menu', async () => {
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
// Right-click on empty canvas (no menu already open)
await page.mouse.click(600, 400, { button: 'right' })
await expect(page.getByTestId('context-menu')).toBeVisible()
// Wait for any async events (native contextmenu follows pointerup)
await page.waitForTimeout(100)
// Menu should still be visible — a leaked native contextmenu must not toggle it closed
await expect(page.getByTestId('context-menu')).toBeVisible()
expect(
await page.evaluate(() => ({
isRightPointing: editor.inputs.getIsRightPointing(),
isPanning: editor.inputs.getIsPanning(),
}))
).toMatchObject({
isRightPointing: false,
isPanning: false,
})
})
test('right-click drag on plain canvas pans without opening context menu', async () => {
const initialCamera = await page.evaluate(() => {
const camera = editor.getCamera()
return { x: camera.x, y: camera.y, z: camera.z }
})
await page.mouse.move(300, 300)
await page.mouse.down({ button: 'right' })
await page.mouse.move(500, 500, { steps: 5 })
expect(
await page.evaluate(() => ({
isPanning: editor.inputs.getIsPanning(),
isRightPointing: editor.inputs.getIsRightPointing(),
}))
).toMatchObject({
isPanning: true,
isRightPointing: true,
})
await page.mouse.up({ button: 'right' })
// Context menu should NOT open after a drag
await expect(page.getByTestId('context-menu')).toBeHidden()
// Camera should have moved
expect(
await page.evaluate((initialCamera) => {
const camera = editor.getCamera()
return camera.x !== initialCamera.x || camera.y !== initialCamera.y
}, initialCamera)
).toBe(true)
// Input state should be clean
expect(
await page.evaluate(() => ({
isPanning: editor.inputs.getIsPanning(),
isRightPointing: editor.inputs.getIsRightPointing(),
}))
).toMatchObject({
isPanning: false,
isRightPointing: false,
})
})
test('can begin a right-click drag after closing the menu', async () => {
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
await page.mouse.click(200, 200, { button: 'right' })
await expect(page.getByTestId('context-menu')).toBeVisible()
await page.mouse.move(600, 400)
const initialCamera = await page.evaluate(() => {
const camera = editor.getCamera()
return { x: camera.x, y: camera.y, z: camera.z }
})
await page.mouse.down({ button: 'right' })
await expect(page.getByTestId('context-menu')).toBeHidden()
await page.mouse.move(700, 500, { steps: 5 })
expect(
await page.evaluate(() => ({
camera: editor.getCamera(),
isPanning: editor.inputs.getIsPanning(),
isRightPointing: editor.inputs.getIsRightPointing(),
}))
).toMatchObject({
isPanning: true,
isRightPointing: true,
})
await page.mouse.up({ button: 'right' })
expect(
await page.evaluate((initialCamera) => {
const camera = editor.getCamera()
return {
cameraChanged:
camera.x !== initialCamera.x ||
camera.y !== initialCamera.y ||
camera.z !== initialCamera.z,
isPanning: editor.inputs.getIsPanning(),
isRightPointing: editor.inputs.getIsRightPointing(),
}
}, initialCamera)
).toMatchObject({
cameraChanged: true,
isPanning: false,
isRightPointing: false,
})
})
test('left-click closes menu and leaves editor idle', async () => {
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
// Move to a clearly empty spot so the right-click doesn't select or hover a shape
await page.mouse.move(800, 600)
await page.mouse.click(800, 600, { button: 'right' })
await expect(page.getByTestId('context-menu')).toBeVisible()
// Left-click elsewhere on empty canvas — should close the menu
await page.mouse.click(900, 700)
await expect(page.getByTestId('context-menu')).toBeHidden()
// Tool state clean, pointer released — regardless of selection state
expect(
await page.evaluate(() => ({
path: editor.getPath(),
isPointing: editor.inputs.getIsPointing(),
isRightPointing: editor.inputs.getIsRightPointing(),
}))
).toMatchObject({
path: 'select.idle',
isPointing: false,
isRightPointing: false,
})
})
test('left-click drag with menu open starts brush selection', async () => {
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
await page.mouse.move(800, 600)
await page.mouse.click(800, 600, { button: 'right' })
await expect(page.getByTestId('context-menu')).toBeVisible()
// Left-click + drag entirely on empty canvas
await page.mouse.move(900, 100)
await page.mouse.down({ button: 'left' })
await page.mouse.move(1100, 300, { steps: 10 })
// Menu closes, editor enters brushing state
await expect(page.getByTestId('context-menu')).toBeHidden()
expect(await page.evaluate(() => editor.getPath())).toBe('select.brushing')
await page.mouse.up({ button: 'left' })
// After release: brush ended, editor idle, pointer released
expect(
await page.evaluate(() => ({
path: editor.getPath(),
isPointing: editor.inputs.getIsPointing(),
}))
).toMatchObject({
path: 'select.idle',
isPointing: false,
})
})
test('consecutive right-clicks keep menu continuously visible', async () => {
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
await page.mouse.click(200, 200, { button: 'right' })
await expect(page.getByTestId('context-menu')).toBeVisible()
// Right-click at a new location — observe visibility frequently during the transition
// to catch any hidden flash between the old menu closing and the new one opening.
await page.mouse.move(600, 400)
const menu = page.getByTestId('context-menu')
await page.mouse.down({ button: 'right' })
await page.mouse.up({ button: 'right' })
// Poll visibility for ~300ms; it must never be hidden during the transition.
const samples = await page.evaluate(async () => {
const results: boolean[] = []
const el = () => document.querySelector('[data-testid="context-menu"]')
for (let i = 0; i < 30; i++) {
results.push(!!el())
await new Promise((r) => setTimeout(r, 10))
}
return results
})
expect(samples.every((v) => v === true)).toBe(true)
await expect(menu).toBeVisible()
})
test('left-click drag works after a right-click drag with menu open', async () => {
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
// Open a menu
await page.mouse.click(200, 200, { button: 'right' })
await expect(page.getByTestId('context-menu')).toBeVisible()
// Right-click + drag to pan; this should release all pointer capture cleanly
await page.mouse.move(600, 400)
await page.mouse.down({ button: 'right' })
await page.mouse.move(700, 500, { steps: 5 })
await page.mouse.up({ button: 'right' })
await expect(page.getByTestId('context-menu')).toBeHidden()
// Now a regular left-click drag must still work — if pointer capture
// leaked to the capture div or canvas, this brush would not engage.
await page.mouse.move(100, 100)
await page.mouse.down({ button: 'left' })
await page.mouse.move(400, 400, { steps: 10 })
expect(await page.evaluate(() => editor.getPath())).toBe('select.brushing')
await page.mouse.up({ button: 'left' })
expect(await page.evaluate(() => editor.getPath())).toBe('select.idle')
})
})

View File

@@ -0,0 +1,134 @@
import { expect, type Page } from '@playwright/test'
import test from '../fixtures/fixtures'
const content = (page: Page) => page.locator('.tlui-dialog__content')
const selectContent = (page: Page) => page.getByTestId('dialog-select.content')
// Click the dialog backdrop at a point that is outside the centered content.
async function clickBackdrop(page: Page) {
const box = await content(page).boundingBox()
if (!box) throw new Error('Could not find dialog content')
// Midpoint of the left-hand backdrop, vertically aligned with the content.
await page.mouse.click(Math.max(5, box.x / 2), box.y + box.height / 2)
}
test.describe('dialogs', () => {
// These exercise backdrop/drag dismissal, which is desktop pointer behaviour.
test.skip(({ isMobile }) => isMobile, 'Dialog dismissal is tested with a desktop pointer')
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:5420/toasts-and-dialogs')
await page.waitForSelector('.tl-canvas')
})
test('a backdrop click dismisses a normal dialog', async ({ page }) => {
await page.getByTestId('show-dialog').click()
await expect(content(page)).toBeVisible()
await clickBackdrop(page)
await expect(content(page)).toHaveCount(0)
})
test('escape dismisses a dialog', async ({ page }) => {
await page.getByTestId('show-dialog').click()
await expect(content(page)).toBeVisible()
await page.keyboard.press('Escape')
await expect(content(page)).toHaveCount(0)
})
test('a press that starts inside the content and ends on the backdrop does not dismiss', async ({
page,
}) => {
await page.getByTestId('show-dialog').click()
await expect(content(page)).toBeVisible()
const box = await content(page).boundingBox()
if (!box) throw new Error('Could not find dialog content')
// Press down inside the content (e.g. selecting text)...
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
// ...drag out onto the backdrop and release.
await page.mouse.move(Math.max(5, box.x / 2), box.y + box.height / 2, { steps: 5 })
await page.mouse.up()
await expect(content(page)).toBeVisible()
})
test('an open select inside a modal is dismissed before the dialog', async ({ page }) => {
await page.getByTestId('show-dialog-with-select').click()
await expect(content(page)).toBeVisible()
// Open the select; its listbox portals into the container.
await page.getByTestId('dialog-select.trigger').click()
await expect(selectContent(page)).toBeVisible()
// The first outside click closes only the select; the dialog stays open.
await clickBackdrop(page)
await expect(selectContent(page)).toHaveCount(0)
await expect(content(page)).toBeVisible()
// A second outside click then closes the dialog.
await clickBackdrop(page)
await expect(content(page)).toHaveCount(0)
})
})
test.describe('stacked dialogs', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:5420/toasts-and-dialogs')
await page.waitForSelector('.tl-canvas')
})
// Stacked modal dialogs must stay interactive — including the topmost dialog's controls
// on a touchscreen. Making lower stacked dialogs non-modal previously left the nested
// dialog unresponsive to taps on mobile, so this runs on mobile as well as desktop.
test('a nested dialog stacks over its parent and stays interactive', async ({
page,
isMobile,
}) => {
const act = (testId: string) =>
isMobile ? page.getByTestId(testId).tap() : page.getByTestId(testId).click()
await act('show-nested-dialog')
await expect(page.getByTestId('dialog-parent')).toBeVisible()
await act('dialog-parent.open-nested')
await expect(page.getByTestId('dialog-nested')).toBeVisible()
// Opening the nested dialog must not collapse the parent.
await expect(page.getByTestId('dialog-parent')).toBeVisible()
// The topmost dialog's control must respond (taps included): closing it leaves the
// parent open underneath.
await act('dialog-nested.confirm')
await expect(page.getByTestId('dialog-nested')).toHaveCount(0)
await expect(page.getByTestId('dialog-parent')).toBeVisible()
})
})
// A touch press is checked against the dialog stack at the click that follows it, not at the
// press, so this needs a genuine touch-to-click sequence to reproduce. The viewport is wide on
// purpose: the editor cancels a touchstart close to the window's edges, which substitutes a
// synthesised click and moves the check earlier than the real one, hiding the bug.
test.describe('stacked dialogs on a wide touch viewport', () => {
test.use({ viewport: { width: 1024, height: 768 }, hasTouch: true, isMobile: true })
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:5420/toasts-and-dialogs')
await page.waitForSelector('.tl-canvas')
})
test('confirming the nested dialog by touch leaves the parent open', async ({ page }) => {
await page.getByTestId('show-nested-dialog').tap()
await expect(page.getByTestId('dialog-parent')).toBeVisible()
await page.getByTestId('dialog-parent.open-nested').tap()
await expect(page.getByTestId('dialog-nested')).toBeVisible()
// By the time the deferred check runs the nested dialog has unmounted, leaving the parent
// topmost — the press that closed the nested dialog must not read as a press outside it.
await page.getByTestId('dialog-nested.confirm').tap()
await expect(page.getByTestId('dialog-nested')).toHaveCount(0)
await expect(page.getByTestId('dialog-parent')).toBeVisible()
})
})

View File

@@ -0,0 +1,298 @@
import test, { expect } from '@playwright/test'
import { Editor } from 'tldraw'
declare const editor: Editor
declare const EDITOR_A: Editor
declare const EDITOR_B: Editor
declare const EDITOR_C: Editor
// We're just testing the events, not the actual results.
test.describe('Focus', () => {
test('focus events', async ({ page }) => {
await page.goto('http://localhost:5420/multiple/full')
await page.waitForSelector('.tl-canvas')
const EditorA = (await page.$(`.A`))!
const EditorB = (await page.$(`.B`))!
const EditorC = (await page.$(`.C`))!
expect(EditorA).toBeTruthy()
expect(EditorB).toBeTruthy()
expect(EditorC).toBeTruthy()
async function isOnlyFocused(id: 'A' | 'B' | 'C' | null) {
let activeElement: string | null = null
const isA = await EditorA.evaluate(
(node) => document.activeElement === node || node.contains(document.activeElement)
)
const isB = await EditorB.evaluate(
(node) => document.activeElement === node || node.contains(document.activeElement)
)
const isC = await EditorC.evaluate(
(node) => document.activeElement === node || node.contains(document.activeElement)
)
activeElement = isA ? 'A' : isB ? 'B' : isC ? 'C' : null
expect(
activeElement,
`Active element should have been ${id}, but was ${activeElement ?? 'null'} instead`
).toBe(id)
await page.evaluate(
({ id }) => {
if (
!(
EDITOR_A.getInstanceState().isFocused === (id === 'A') &&
EDITOR_B.getInstanceState().isFocused === (id === 'B') &&
EDITOR_C.getInstanceState().isFocused === (id === 'C')
)
) {
throw Error('isFocused is not correct')
}
},
{ id }
)
}
// Component A has autofocus
// Component B does not
// Component C does not
// Component B and C share persistence id
await (await page.$('body'))?.click()
await isOnlyFocused('A')
await EditorA.click()
await isOnlyFocused('A')
await EditorA.click()
await isOnlyFocused('A')
await EditorB.click()
await isOnlyFocused('B')
// Escape does not break focus
await page.keyboard.press('Escape')
await isOnlyFocused('B')
})
test('kbds when not focused', async ({ page }) => {
await page.goto('http://localhost:5420/multiple/full')
await page.waitForSelector('.tl-canvas')
// Should not have any shapes on the page
expect(await page.evaluate(() => EDITOR_A.getCurrentPageShapes().length)).toBe(0)
const EditorA = (await page.$(`.A`))!
await page.keyboard.press('r')
await EditorA.click({ position: { x: 100, y: 100 } })
// Should not have created a shape
expect(await page.evaluate(() => EDITOR_A.getCurrentPageShapes().length)).toBe(1)
const TextArea = page.getByTestId(`textarea`)
await TextArea.focus()
await page.keyboard.type('hello world')
await page.keyboard.press('Control+A')
await page.keyboard.press('Delete')
// Should not have deleted the page
expect(await page.evaluate(() => EDITOR_A.getCurrentPageShapes().length)).toBe(1)
})
test('kbds when focused', async ({ page }) => {
await page.goto('http://localhost:5420/multiple/full')
await page.waitForSelector('.tl-canvas')
const EditorA = (await page.$(`.A`))!
const EditorB = (await page.$(`.B`))!
const EditorC = (await page.$(`.C`))!
expect(EditorA).toBeTruthy()
expect(EditorB).toBeTruthy()
expect(EditorC).toBeTruthy()
await (await page.$('body'))?.click()
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).toBe(
null
)
expect(await EditorB.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).toBe(
null
)
await page.keyboard.press('d')
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).not.toBe(
null
)
expect(await EditorB.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).toBe(
null
)
await EditorB.click()
await page.waitForTimeout(100) // takes 30ms or so to focus
await page.keyboard.press('d')
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).not.toBe(
null
)
expect(await EditorB.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).not.toBe(
null
)
})
test('kbds after clicking on ui elements', async ({ page }) => {
await page.goto('http://localhost:5420/end-to-end')
await page.waitForSelector('.tl-canvas')
const EditorA = (await page.$(`.tl-container`))!
expect(EditorA).toBeTruthy()
const drawButton = await EditorA.$('.tlui-button[data-testid="tools.draw"]')
// select button should be selected, not the draw button
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).toBe(
null
)
await drawButton?.click()
// draw button should be selected now
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).not.toBe(
null
)
await page.keyboard.press('v')
// select button should be selected again
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).toBe(
null
)
})
test('still focuses text after clicking on style button', async ({ page }) => {
await page.goto('http://localhost:5420/end-to-end')
await page.waitForSelector('.tl-canvas')
const EditorA = (await page.$(`.tl-container`))!
expect(EditorA).toBeTruthy()
// Create a new note, text should be focused
await page.keyboard.press('n')
await (await page.$('body'))?.click()
await page.waitForSelector('.tl-shape')
const blueButton = await page.$('.tlui-button[data-testid="style.color.blue"]')
await blueButton?.click()
// Text should still be focused.
expect(
await page.evaluate(() => !!document.querySelector('.tl-shape div[contenteditable]:focus'))
).toBe(true)
})
test('edit->edit, focus stays in the text areas when going from shape-to-shape', async ({
page,
}) => {
await page.goto('http://localhost:5420/end-to-end')
await page.waitForSelector('.tl-canvas')
const EditorA = (await page.$(`.tl-container`))!
expect(EditorA).toBeTruthy()
// Create a new note, text should be focused
await page.keyboard.press('n')
await (await page.$('body'))?.click()
await page.waitForSelector('.tl-shape')
await page.keyboard.type('test')
const firstNoteId = await page.evaluate(() => editor.getEditingShapeId())
const firstNote = page.locator(`.tl-shape[data-shape-id="${firstNoteId}"]`)
// create new note next to it
await page.keyboard.press('Tab')
await expect(page.locator('.tl-shape')).toHaveCount(2)
// Tab pans to the new note; let the camera settle so the first note is where
// the locator resolves it before clicking.
await page.waitForFunction(() => editor.getCameraState() === 'idle')
await firstNote.click()
await page.waitForTimeout(1000)
// The first note, not the one Tab created, should be the focused one.
await expect(firstNote.locator('div[contenteditable]')).toBeFocused()
})
test('exits edit mode when dragging from text label with blurred input', async ({ page }) => {
await page.goto('http://localhost:5420/end-to-end')
await page.waitForSelector('.tl-canvas')
// Create a geo shape with text
await page.keyboard.press('r')
await page.mouse.click(200, 200)
await page.mouse.click(400, 400)
// Double-click to edit the shape
await page.mouse.dblclick(300, 300)
await page.waitForTimeout(100)
// Verify we're in edit mode
expect(
await page.evaluate(() => !!document.querySelector('.tl-shape [contenteditable]:focus'))
).toBe(true)
// Click near the text and drag (this should blur the input and exit edit mode)
await page.mouse.move(300, 300)
await page.mouse.down()
await page.mouse.move(350, 350)
await page.waitForTimeout(100)
// Verify we've exited edit mode (no focused contenteditable)
expect(
await page.evaluate(() => !!document.querySelector('.tl-shape [contenteditable]:focus'))
).toBe(false)
await page.mouse.up()
})
test('selects all text when clicking on blurred text input in editing mode', async ({ page }) => {
await page.goto('http://localhost:5420/end-to-end')
await page.waitForSelector('.tl-canvas')
// Create geo shape and enter edit mode
await page.keyboard.press('r')
await page.mouse.click(200, 200)
await page.mouse.click(400, 400)
await page.mouse.dblclick(300, 300)
// Wait for contenteditable to be focused before typing
await page.waitForFunction(
() => document.activeElement === document.querySelector('.tl-shape [contenteditable]')
)
// Type text
await page.keyboard.type('test', { delay: 10 })
// Verify initial text
const contenteditable = page.locator('.tl-shape [contenteditable]')
await expect(contenteditable).toHaveText('test')
// Blur input while staying in editing mode by clicking on the text label (but not the input itself)
await page.mouse.click(200, 230)
await page.waitForTimeout(100)
await page.waitForFunction(() => document.getSelection()?.toString() === 'test')
expect(
await page.evaluate(() => !!document.querySelector('.tl-shape div[contenteditable]:focus'))
).toBe(true)
})
})

View File

@@ -0,0 +1,248 @@
import { expect } from '@playwright/test'
import { Editor, TLAssetId, TLImageShape } from 'tldraw'
import test from '../fixtures/fixtures'
import { setupOrReset } from '../shared-e2e'
declare const editor: Editor
test.describe('Image toolbar behaviour', () => {
test.beforeEach(setupOrReset)
test.beforeEach(async ({ page, isMobile }) => {
// TODO: the mobile e2e test may need special handling
if (isMobile) return
// Create an image shape by inserting an image
await page.evaluate(() => {
const imageAsBase64 =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
const assetId = 'asset:img' as TLAssetId
editor.createAssets([
{
id: assetId,
typeName: 'asset',
type: 'image',
props: {
src: imageAsBase64,
w: 100,
h: 100,
name: 'test',
isAnimated: false,
mimeType: 'image/png',
},
meta: {},
},
])
editor.createShape({
type: 'image',
x: 250,
y: 250,
props: {
w: 200,
h: 150,
crop: {
topLeft: { x: 0, y: 0 },
bottomRight: { x: 1, y: 1 },
},
assetId,
},
})
})
// Select the image
await page.mouse.click(300, 300)
// Wait for the toolbar to animate in
await page.waitForTimeout(150)
})
test('zoom slider changes image crop', async ({ page, isMobile }) => {
if (isMobile) return
const manipulator = page.getByTestId('tool.image-crop')
await manipulator.click()
// Find and use the slider
const slider = page.getByTestId('tool.image-zoom')
await slider.click()
// Move to a higher zoom
await page.mouse.move(
await slider.boundingBox().then((box) => box!.x + 100),
await slider.boundingBox().then((box) => box!.y + 20)
)
await page.mouse.down()
await page.mouse.up()
const crop = await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
return imageShape.props.crop
})
expect(crop!.bottomRight.x).toBeCloseTo(0.6732, 2)
expect(crop!.bottomRight.y).toBeCloseTo(0.6732, 2)
expect(crop!.topLeft.y).toBeCloseTo(0.3267, 2)
expect(crop!.topLeft.y).toBeCloseTo(0.3267, 2)
})
test('aspect ratio dropdown changes image crop', async ({ page, isMobile }) => {
if (isMobile) return
const manipulator = page.getByTestId('tool.image-crop')
await manipulator.click()
// Open aspect ratio dropdown
await page.getByTestId('tool.image-aspect-ratio').click()
// Select the square option
await page.getByText('Square').click()
// Check that the image has been cropped to square
const isSquare = await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
const aspectRatio = imageShape.props.w / imageShape.props.h
return Math.abs(aspectRatio - 1) < 0.01 // Check if width/height ratio is approximately 1
})
expect(isSquare).toBe(true)
// Try another aspect ratio - landscape
await page.getByText('Landscape').click()
// Check that the image has been cropped to landscape (4:3)
const isLandscape = await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
const aspectRatio = imageShape.props.w / imageShape.props.h
return Math.abs(aspectRatio - 4 / 3) < 0.01 // Check if width/height ratio is approximately 4/3
})
expect(isLandscape).toBe(true)
})
test.skip('flip buttons flip the image', async ({ page, isMobile }) => {
if (isMobile) return
const manipulator = page.getByTestId('tool.image-crop')
await manipulator.click()
// Get initial state
const initialState = await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
return {
w: imageShape.props.w,
h: imageShape.props.h,
flipX: imageShape.props.flipX ?? false,
flipY: imageShape.props.flipY ?? false,
}
})
// Click flip horizontal button
await page.getByTestId('tool.image-flip-horizontal').click()
// Check horizontal flip state
const afterHorizontalFlip = await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
return {
w: imageShape.props.w,
h: imageShape.props.h,
flipX: imageShape.props.flipX ?? false,
flipY: imageShape.props.flipY ?? false,
}
})
expect(afterHorizontalFlip.flipX).not.toEqual(initialState.flipX)
expect(afterHorizontalFlip.w).toEqual(initialState.w) // Width shouldn't change
// Click flip vertical button
await page.getByTestId('tool.image-flip-vertical').click()
// Check vertical flip state
const afterVerticalFlip = await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
return {
w: imageShape.props.w,
h: imageShape.props.h,
flipX: imageShape.props.flipX ?? false,
flipY: imageShape.props.flipY ?? false,
}
})
expect(afterVerticalFlip.flipY).not.toEqual(initialState.flipY)
expect(afterVerticalFlip.h).toEqual(initialState.h) // Height shouldn't change
})
test('alt text button opens alt text editor', async ({ page, isMobile }) => {
if (isMobile) return
// Click the Alt Text button
await page.getByTestId('tool.image-alt-text').click()
// Enter some alt text
await page.getByTestId('media-toolbar.alt-text-input').fill('A test image for e2e testing')
// Save the alt text
await page.getByTestId('tool.media-alt-text-confirm').click()
// Check that the alt text was saved
const altText = await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
return imageShape.props.altText
})
expect(altText).toBe('A test image for e2e testing')
})
test('image replacement works', async ({ page, isMobile }) => {
if (isMobile) return
// Get initial image asset id
const initialAssetId = await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
return imageShape.props.assetId
})
// Click the replace image button
await page.getByTestId('tool.image-replace').click()
// This would typically open a file dialog, which is hard to test in Playwright
// Instead, we'll directly trigger the image replacement through evaluation
await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
const newImageAsBase64 =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEklEQVR42mNk+M9QzwAEjDAGACCDAv8cI7IoAAAAAElFTkSuQmCC'
const newAssetId = 'asset:img-new' as TLAssetId
editor.createAssets([
{
id: newAssetId,
typeName: 'asset',
type: 'image',
props: {
src: newImageAsBase64,
w: 100,
h: 100,
name: 'test',
isAnimated: false,
mimeType: 'image/png',
},
meta: {},
},
])
editor.updateShape({
id: imageShape.id,
type: imageShape.type,
props: {
assetId: newAssetId,
},
})
})
// Check that asset id changed
const newAssetId = await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
return imageShape.props.assetId
})
expect(newAssetId).not.toEqual(initialAssetId)
})
})

View File

@@ -0,0 +1,891 @@
import { Page, expect } from '@playwright/test'
import { Editor } from 'tldraw'
import test from '../fixtures/fixtures'
import { setupPage, setupPageWithShapes } from '../shared-e2e'
declare const editor: Editor
declare const __tldraw_ui_event: { name: string }
// We're just testing the events, not the actual results.
let page: Page
test.describe('Keyboard Shortcuts', () => {
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await setupPage(page)
})
test('tools', async ({ isMobile }) => {
const geoToolKds = [
['r', 'rectangle'],
['o', 'ellipse'],
]
for (const [key, geo] of geoToolKds) {
await page.keyboard.press('v') // set back to select
await page.keyboard.press(key)
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'select-tool',
data: { id: `geo-${geo}`, source: 'kbd' },
})
}
const simpleToolKbds = [
['v', 'select'],
['h', 'hand'],
]
for (const [key, tool] of simpleToolKbds) {
await page.keyboard.press('v') // set back to select
await page.keyboard.press(key)
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'select-tool',
data: { id: tool, source: 'kbd' },
})
}
const shapeToolKbds = [
['d', 'draw'],
['x', 'draw'],
['a', 'arrow'],
['l', 'line'],
['f', 'frame'],
['n', 'note'],
['f', 'frame'],
['e', 'eraser'],
['k', 'laser'],
['t', 'text'],
]
for (const [key, tool] of shapeToolKbds) {
await page.keyboard.press('v') // set back to select
await page.keyboard.press(key)
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'select-tool',
data: { id: tool, source: 'kbd' },
})
}
// make sure that the first dropdown item is rectangle
await page.keyboard.press('r')
const positionalToolKbds = [
['1', 'select'],
['2', 'hand'],
['3', 'draw'],
['4', 'eraser'],
['5', 'arrow'],
['6', 'text'],
]
if (isMobile) {
// on mobile, the last item (first from the dropdown) is 6
positionalToolKbds.push(['7', 'geo-rectangle'])
} else {
// on desktop, the last item (first from the dropdown) is 9
positionalToolKbds.push(['9', 'geo-rectangle'])
}
for (const [key, tool] of positionalToolKbds) {
await page.keyboard.press('v') // set back to select
await page.keyboard.press(key)
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'select-tool',
data: { id: tool, source: 'toolbar' },
})
}
})
})
test.describe('Keyboard Shortcuts', () => {
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await setupPage(page)
// Make some shapes
await page.keyboard.press('r')
await page.mouse.click(100, 100)
await page.keyboard.press('r')
await page.mouse.click(250, 250)
await page.keyboard.press('v')
})
test('Zoom in', async () => {
await page.keyboard.press('Control+=')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'zoom-in',
data: { source: 'kbd' },
})
})
test('Zoom out', async () => {
await page.keyboard.press('Control+-')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'zoom-out',
data: { source: 'kbd' },
})
})
test('Zoom to fit', async () => {
await page.keyboard.press('Shift+1')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'zoom-to-fit',
data: { source: 'kbd' },
})
})
test('Zoom to selection', async () => {
await page.keyboard.press('Shift+2')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'zoom-to-selection',
data: { source: 'kbd' },
})
})
test('Zoom to 100', async () => {
await page.keyboard.press('Shift+0')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'reset-zoom',
data: { source: 'kbd' },
})
})
/* ---------------------- Files --------------------- */
// new-project — Cmd+N
// open — Cmd+O
// save — Cmd+S
// save-as — Cmd+Shift+S
// upload-media — Cmd+I
/* -------------------- Clipboard ------------------- */
// await page.keyboard.press('Control+c')
// expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
// name: 'copy',
// data: { source: 'kbd' },
// })
// await page.keyboard.press('Control+v')
// expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
// name: 'paste',
// data: { source: 'kbd' },
// })
// await page.keyboard.press('Control+x')
// expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
// name: 'cut',
// data: { source: 'kbd' },
// })
test('Toggle grid mode', async () => {
await page.keyboard.press("Control+'")
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'toggle-grid-mode',
data: { source: 'kbd' },
})
})
test('Toggle dark mode', async () => {
await page.keyboard.press('Control+/')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'color-scheme',
data: { source: 'kbd' },
})
})
test('Toggle tool lock', async () => {
await page.keyboard.press('q')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'toggle-tool-lock',
data: { source: 'kbd' },
})
})
test('Copy hovered styles', async () => {
// The action copies styles from the shape under the pointer, so create a shape in the
// middle of the viewport and move the mouse over it before pressing the shortcut.
const point = await page.evaluate(() => {
const center = editor.getViewportPageBounds().center
editor.createShape({
type: 'geo',
x: center.x - 50,
y: center.y - 50,
props: { w: 100, h: 100, fill: 'solid' },
})
const screen = editor.pageToScreen(center)
return { x: screen.x, y: screen.y }
})
await page.mouse.move(point.x, point.y)
await page.keyboard.press('Shift+q')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'copy-hovered-styles',
data: { source: 'kbd' },
})
})
})
test.describe('Actions on shapes', () => {
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await setupPage(page)
})
/* -------------- Operations on Shapes -------------- */
test('Operations on shapes', async () => {
await setupPageWithShapes(page)
// needs shapes on the canvas
// select-all — Cmd+A
await page.keyboard.press('Control+a')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'select-all-shapes',
data: { source: 'kbd' },
})
// flip-h — Shift+H
await page.keyboard.press('Shift+h')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'flip-shapes',
data: { operation: 'horizontal', source: 'kbd' },
})
// flip-v — Shift+V
await page.keyboard.press('Shift+v')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'flip-shapes',
data: { operation: 'vertical', source: 'kbd' },
})
// move-to-front — ]
await page.keyboard.press(']')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'reorder-shapes',
data: { operation: 'toFront', source: 'kbd' },
})
// move-forward — Alt+]
await page.keyboard.press('Alt+]')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'reorder-shapes',
data: { operation: 'forward', source: 'kbd' },
})
// move-to-back — [
await page.keyboard.press('[')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'reorder-shapes',
data: { operation: 'toBack', source: 'kbd' },
})
// move-backward — Alt+[
await page.keyboard.press('Alt+[')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'reorder-shapes',
data: { operation: 'backward', source: 'kbd' },
})
// group — Cmd+G
await page.keyboard.press('Control+g')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'group-shapes',
data: { source: 'kbd' },
})
// ungroup — Cmd+Shift+G
await page.keyboard.press('Control+Shift+g')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'ungroup-shapes',
data: { source: 'kbd' },
})
// duplicate — Cmd+D
await page.keyboard.press('Control+d')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'duplicate-shapes',
data: { source: 'kbd' },
})
// align left — Alt+A
await page.keyboard.press('Alt+a')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'align-shapes',
data: { operation: 'left', source: 'kbd' },
})
// align right — Alt+D
await page.keyboard.press('Alt+d')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'align-shapes',
data: { operation: 'right', source: 'kbd' },
})
// align top — Alt+W
await page.keyboard.press('Alt+w')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'align-shapes',
data: { operation: 'top', source: 'kbd' },
})
// align bottom — Alt+W'
await page.keyboard.press('Alt+s')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'align-shapes',
data: { operation: 'bottom', source: 'kbd' },
})
// Copy as SVG — this should have a clipboard error
// await page.keyboard.press('Control+Shift+c')
// expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
// name: 'copy-as',
// data: { format: 'svg', source: 'kbd' },
// })
// delete — backspace
await page.keyboard.press('Control+a') // selected
await page.keyboard.press('Backspace')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'delete-shapes',
data: { source: 'kbd' },
})
// delete — ⌫
// Make some shapes and select them
await page.keyboard.press('r')
await page.mouse.click(100, 100)
await page.keyboard.press('r')
await page.mouse.click(250, 250)
await page.keyboard.press('v')
await page.keyboard.press('Control+a')
await page.keyboard.press('Delete')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'delete-shapes',
data: { source: 'kbd' },
})
// Next, previous pages — Alt+ArrowLeft, Alt+ArrowRight
// Try a previous page move. We can't go lower since we're on the first page.
// So the most recent event should be the previous delete
await page.keyboard.press('Alt+ArrowLeft')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'delete-shapes',
data: { source: 'kbd' },
})
// Next page. Since there's only one page and the page is empty, nothing should happen.
await page.keyboard.press('Alt+ArrowRight')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'delete-shapes',
data: { source: 'kbd' },
})
// make something on the new page and delete it
await page.keyboard.press('r')
await page.mouse.click(100, 100)
// If there's something on the page, we can create the next page by moving up
await page.keyboard.press('Alt+ArrowRight')
// We'll also have a change page here... but the most recent will be the new page
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'new-page',
data: { source: 'kbd' },
})
// We can go back down...
await page.keyboard.press('Alt+ArrowLeft')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'change-page',
data: { source: 'kbd', direction: 'prev' },
})
// We can go up again
await page.keyboard.press('Alt+ArrowRight')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'change-page',
data: { source: 'kbd', direction: 'next' },
})
// We can back down and up with the up and down keys too...
await page.keyboard.press('Alt+ArrowUp')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'change-page',
data: { source: 'kbd', direction: 'prev' },
})
await page.keyboard.press('Alt+ArrowDown')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'change-page',
data: { source: 'kbd', direction: 'next' },
})
/* ---------------------- Misc ---------------------- */
// These actions require selected shapes to run
await page.keyboard.press('r')
await page.mouse.click(100, 100)
await page.keyboard.press('r')
await page.mouse.click(100, 300)
await page.keyboard.press('r')
await page.mouse.click(300, 300)
// First verify they don't fire when nothing is selected
await page.keyboard.press('Escape') // deselect all
await page.keyboard.press('Escape') // deselect all
const prevEvent = await page.evaluate(() => __tldraw_ui_event)
await page.keyboard.press('Shift+l')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject(prevEvent)
await page.keyboard.press('Control+Shift+Alt+=')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject(prevEvent)
await page.keyboard.press('Control+Shift+Alt+-')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject(prevEvent)
// Now select shapes and verify the actions fire
await page.keyboard.press('Control+a')
await page.keyboard.press('Control+Shift+Alt+=')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'enlarge-shapes',
})
await page.keyboard.press('Control+Shift+Alt+-')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'shrink-shapes',
})
// toggle lock (must be the last one here)
await page.keyboard.press('Shift+l')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'toggle-lock',
})
// await page.keyboard.press('Control+i')
// expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
// name: 'open-menu',
// data: { source: 'dialog' },
// })
// await page.keyboard.press('Control+u')
// expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
// name: 'open-menu',
// data: { source: 'dialog' },
// })
})
// TODO: Annoyingly, the alt key is being 'released' in the above test suite.
// I'll need to figure that out in a separate PR. In the meantime,
// this pulls out the rotate tests into a separate test suite.
test('rotate', async () => {
await setupPageWithShapes(page)
await page.keyboard.press('Control+a')
// rotate — Shift+ArrowLeft, Shift+ArrowRight
await page.keyboard.press('Shift+.')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'rotate-cw',
data: { source: 'kbd', fine: false },
})
await page.keyboard.press('Shift+,')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'rotate-ccw',
data: { source: 'kbd', fine: false },
})
// rotate — Shift+Alt+ArrowLeft, Shift+Alt+ArrowRight
await page.keyboard.press('Shift+Alt+.')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'rotate-cw',
data: { source: 'kbd', fine: true },
})
await page.keyboard.press('Shift+Alt+,')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'rotate-ccw',
data: { source: 'kbd', fine: true },
})
})
})
test.describe('Delete bug', () => {
test.beforeEach(async ({ browser }) => {
page = await browser.newPage()
await setupPage(page)
})
test('delete bug without drag', async () => {
await page.keyboard.press('r')
await page.mouse.click(100, 100)
await page.keyboard.press('Backspace')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'delete-shapes',
data: { source: 'kbd' },
})
})
test('delete bug with drag', async () => {
await page.keyboard.press('r')
await page.mouse.down()
await page.mouse.move(100, 100)
await page.mouse.up()
await page.keyboard.press('Backspace')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'delete-shapes',
data: { source: 'kbd' },
})
})
})
test.describe('Shape Navigation', () => {
test.beforeEach(async ({ browser }) => {
page = await browser.newPage()
await setupPage(page)
})
test('Tab navigation between shapes', async ({ isMobile }) => {
if (isMobile) return // can't test this on mobile
// Create multiple shapes
await page.keyboard.press('r')
await page.mouse.click(100, 100)
await page.keyboard.press('r')
await page.mouse.click(250, 100)
await page.keyboard.press('r')
await page.mouse.click(400, 100)
await page.keyboard.press('v')
// Click on the first shape to select it
await page.mouse.click(100, 100)
// Navigate forward with Tab
await page.keyboard.press('Tab')
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
x: 150,
y: 0,
})
// Navigate backward with Shift+Tab
await page.keyboard.press('Shift+Tab')
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
x: 0,
y: 0,
})
})
test('Directional navigation between shapes', async ({ isMobile }) => {
if (isMobile) return // can't test this on mobile
// Create shapes in different directions
await page.keyboard.press('r')
await page.mouse.click(200, 200) // Center shape
await page.keyboard.press('r')
await page.mouse.click(300, 200) // Right shape
await page.keyboard.press('r')
await page.mouse.click(100, 200) // Left shape
await page.keyboard.press('r')
await page.mouse.click(200, 100) // Up shape
await page.keyboard.press('r')
await page.mouse.click(200, 300) // Down shape
await page.keyboard.press('v')
// Select center shape
await page.mouse.click(200, 200)
// Test navigation to the right
await page.keyboard.press('Control+ArrowRight')
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
x: 200,
y: 100,
})
// Navigate back to center
await page.mouse.click(200, 200)
// Disabled: I think this conflicts with browser nav or something.
// // Test navigation to the left
// await page.keyboard.press('Control+ArrowLeft')
// expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
// x: 200,
// y: 100
// })
// Navigate back to center
await page.mouse.click(200, 200)
// Test navigation up
await page.keyboard.press('Control+ArrowUp')
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
x: 100,
y: 100,
})
// Navigate back to center
await page.mouse.click(200, 200)
// Test navigation down
await page.keyboard.press('Control+ArrowDown')
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
x: 100,
y: 200,
})
})
test('Container navigation (entering and exiting containers)', async ({ isMobile }) => {
if (isMobile) return // can't test this on mobile
// Create a frame
await page.keyboard.press('f')
await page.mouse.down()
await page.mouse.move(300, 300)
await page.mouse.up()
// Create a shape inside the frame
await page.keyboard.press('r')
await page.mouse.click(200, 200)
await page.keyboard.press('v')
// Navigate up to the parent (frame) with Control+Shift+Up
await page.keyboard.press('Control+Shift+ArrowUp')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()
return shape && shape.type === 'frame'
})
).toBe(true)
// Navigate down to the first child with Control+Shift+Down
await page.keyboard.press('Control+Shift+ArrowDown')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()
return shape && shape.type === 'geo'
})
).toBe(true)
// Now test with a group
// Create multiple shapes to group
await page.keyboard.press('v')
await page.keyboard.press('r')
await page.mouse.click(450, 200)
await page.keyboard.press('r')
await page.mouse.click(475, 200)
// Select both shapes
await page.keyboard.press('v')
await page.mouse.move(310, 50)
await page.mouse.down()
await page.mouse.move(310, 50)
await page.mouse.move(700, 500)
await page.mouse.up()
// Group them
await page.keyboard.press('Control+g')
// Navigate up to the group with Control+Shift+Up
await page.keyboard.press('Control+Shift+ArrowUp')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()
return shape && shape.type === 'group'
})
).toBe(true)
// Navigate down to a child in the group
await page.keyboard.press('Control+Shift+ArrowDown')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()
return shape && shape.type === 'geo'
})
).toBe(true)
})
test('Tab navigation respects container boundaries', async ({ isMobile }) => {
if (isMobile) return // can't test this on mobile
// Clear the canvas and reset the view
await page.keyboard.press('Control+a')
await page.keyboard.press('Backspace')
await page.keyboard.press('v')
// Create a frame
await page.keyboard.press('f')
await page.mouse.move(100, 100)
await page.mouse.down()
await page.mouse.move(300, 300)
await page.mouse.up()
// Create shapes inside the frame
await page.keyboard.press('r')
await page.mouse.click(150, 150) // Shape inside frame
await page.keyboard.press('r')
await page.mouse.click(250, 150) // Another shape inside frame
// Create shapes outside the frame
await page.keyboard.press('r')
await page.mouse.click(400, 150) // Shape outside frame
await page.keyboard.press('r')
await page.mouse.click(400, 250) // Another shape outside frame
await page.keyboard.press('v')
// Test 1: Tab from outside shape should only navigate between outside shapes
await page.mouse.click(400, 150) // Select first outside shape
// Tab to next outside shape
await page.keyboard.press('Tab')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()!
return { x: Math.round(shape.x), y: Math.round(shape.y) }
})
).toMatchObject({
x: 100,
y: 100,
})
// Tab again should wrap around to first outside shape
await page.keyboard.press('Tab')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()!
return { x: Math.round(shape.x), y: Math.round(shape.y) }
})
).toMatchObject({
x: 300,
y: 50,
})
// Test 2: Tab from inside shape should only navigate between shapes in the same container
await page.mouse.click(150, 150) // Select first inside shape
// Tab to next shape inside frame
await page.keyboard.press('Tab')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()!
return { x: Math.round(shape.x), y: Math.round(shape.y) }
})
).toMatchObject({
x: -50,
y: -50,
})
// Tab again should wrap around to first shape inside frame
await page.keyboard.press('Tab')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()!
return { x: Math.round(shape.x), y: Math.round(shape.y) }
})
).toMatchObject({
x: 50,
y: -50,
})
})
test('Tab navigation with nested containers', async ({ isMobile }) => {
if (isMobile) return // can't test this on mobile
// Clear the canvas and reset the view
await page.keyboard.press('Control+a')
await page.keyboard.press('Backspace')
await page.keyboard.press('v')
// Create an outer frame
await page.keyboard.press('f')
await page.mouse.move(100, 100)
await page.mouse.down()
await page.mouse.move(500, 400)
await page.mouse.up()
// Create an inner frame inside the outer frame
await page.keyboard.press('f')
await page.mouse.move(150, 150)
await page.mouse.down()
await page.mouse.move(300, 300)
await page.mouse.up()
// Create shapes directly inside outer frame
await page.keyboard.press('r')
await page.mouse.click(400, 200) // Shape in outer frame
await page.keyboard.press('r')
await page.mouse.click(400, 300) // Another shape in outer frame
// Create shapes inside inner frame
await page.keyboard.press('r')
await page.mouse.click(200, 200) // Shape in inner frame
await page.keyboard.press('r')
await page.mouse.click(250, 200) // Another shape in inner frame
await page.keyboard.press('v')
// Test: Tab from inner frame shape should only navigate within inner frame
await page.mouse.click(200, 200) // Select first shape in inner frame
// Tab to next shape in inner frame
await page.keyboard.press('Tab')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()!
const parent = editor.getShape(shape.parentId)
return {
shapeX: Math.round(shape.x),
shapeY: Math.round(shape.y),
isInInnerFrame:
parent && parent.type === 'frame' && parent.parentId !== editor.getCurrentPageId(),
}
})
).toMatchObject({
shapeX: 0,
shapeY: -50,
isInInnerFrame: true,
})
// Tab should never select shapes from outer frame
await page.keyboard.press('Tab')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()!
const parent = editor.getShape(shape.parentId)
return {
isInInnerFrame:
parent && parent.type === 'frame' && parent.parentId !== editor.getCurrentPageId(),
}
})
).toMatchObject({
isInInnerFrame: true,
})
})
test('Tab navigation does not happen when alt key is pressed', async ({ isMobile }) => {
if (isMobile) return // can't test this on mobile
// Create multiple shapes
await page.keyboard.press('r')
await page.mouse.click(100, 100)
await page.keyboard.press('r')
await page.mouse.click(250, 100)
await page.keyboard.press('r')
await page.mouse.click(400, 100)
await page.keyboard.press('v')
// Click on the first shape to select it
await page.mouse.click(100, 100)
await page.keyboard.press('Alt+Tab')
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
x: 0,
y: 0,
})
})
})

View File

@@ -0,0 +1,182 @@
import { expect } from '@playwright/test'
import test from '../fixtures/fixtures'
// Helper function to set up license testing
async function setupLicenseTest(page: any, licenseKey: string) {
await page.addInitScript(`
window.__TLDRAW_LICENSE_KEY__ = "${licenseKey}";
`)
await page.goto('http://localhost:5420/end-to-end')
await page.waitForTimeout(6000) // Wait for license processing
}
test.describe('Internal license', () => {
test('does not render editor for expired internal license', async ({ page }) => {
const expiredInternalLicenseKey =
'tldraw-/WyJ0ZXN0LWludGVybmFsLWV4cGlyZWQtNm1vbnRocyIsWyJsb2NhbGhvc3QiXSw1LCIyMDI1LTAzLTAyVDA5OjM5OjMxLjYzMVoiXQ==.efmj2rGm9PqHqW4HqYE420Nomdubcm693r2gr+WWaIRQoA+I5BNJ2Z2kan0SdbXKcWj4oeyNruSeo7ixtRmfbg=='
const consoleMessages: string[] = []
page.on('console', (msg) => {
consoleMessages.push(msg.text())
})
await setupLicenseTest(page, expiredInternalLicenseKey)
await expect(page.getByTestId('tl-license-expired')).toBeAttached()
// The actual editor canvas should not be rendered
await expect(page.locator('.tl-canvas')).not.toBeVisible()
// Check that console contains license expiry message
const hasExpiredMessage = consoleMessages.some((msg) =>
msg.includes('Your tldraw license has been expired for more than 30 days!')
)
expect(hasExpiredMessage).toBe(true)
})
})
test.describe('Watermarked license', () => {
test('shows watermark with default license key', async ({ page }) => {
// Don't set any license key - this should use our default license that shows the watermark
await page.goto('http://localhost:5420/end-to-end')
await page.waitForSelector('.tl-canvas')
// The editor should render normally
await expect(page.locator('.tl-canvas')).toBeVisible()
// The watermark should be visible (default license has WITH_WATERMARK flag)
await expect(page.getByTestId('tl-watermark-licensed')).toBeVisible()
// The unlicensed watermark should NOT be visible
await expect(page.getByTestId('tl-watermark-unlicensed')).not.toBeVisible()
})
})
test.describe('Unlicensed', () => {
test('shows unlicensed watermark when no license key provided', async ({ page }) => {
const consoleMessages: string[] = []
page.on('console', (msg) => {
consoleMessages.push(msg.text())
})
// Explicitly set no license key by setting it to null
await page.addInitScript(`
window.__TLDRAW_LICENSE_KEY__ = null;
`)
await page.goto('http://localhost:5420/end-to-end')
await page.waitForSelector('.tl-canvas')
// In development mode (localhost), the editor should render normally with watermark
await expect(page.locator('.tl-canvas')).toBeVisible()
// The unlicensed watermark should be visible since we have no license
await expect(page.getByTestId('tl-watermark-unlicensed')).toBeVisible()
// The watermark should NOT be visible
await expect(page.getByTestId('tl-watermark-licensed')).not.toBeVisible()
// License gate should not be visible (editor is not blocked)
await expect(page.getByTestId('tl-license-expired')).not.toBeVisible()
// Debug: Check what license key is being used (should be null)
const licenseKeyUsed = await page.evaluate(() => {
return (window as any).__TLDRAW_LICENSE_KEY__
})
expect(licenseKeyUsed).toBe(null)
})
test('shows unlicensed watermark when license key is undefined', async ({ page }) => {
// Explicitly set license key to undefined
await page.addInitScript(`
window.__TLDRAW_LICENSE_KEY__ = undefined;
`)
await page.goto('http://localhost:5420/end-to-end')
await page.waitForSelector('.tl-canvas')
// In development mode (localhost), the editor should render normally with watermark
await expect(page.locator('.tl-canvas')).toBeVisible()
// The unlicensed watermark should be visible since we have no license
await expect(page.getByTestId('tl-watermark-unlicensed')).toBeVisible()
// The watermark should NOT be visible
await expect(page.getByTestId('tl-watermark-licensed')).not.toBeVisible()
// License gate should not be visible (editor is not blocked)
await expect(page.getByTestId('tl-license-expired')).not.toBeVisible()
})
test('shows unlicensed watermark when license key is empty string', async ({ page }) => {
// Set license key to empty string
await page.addInitScript(`
window.__TLDRAW_LICENSE_KEY__ = "";
`)
await page.goto('http://localhost:5420/end-to-end')
await page.waitForSelector('.tl-canvas')
// In development mode (localhost), the editor should render normally with watermark
await expect(page.locator('.tl-canvas')).toBeVisible()
// The unlicensed watermark should be visible since we have no valid license
await expect(page.getByTestId('tl-watermark-unlicensed')).toBeVisible()
// The watermark should NOT be visible
await expect(page.getByTestId('tl-watermark-licensed')).not.toBeVisible()
// License gate should not be visible (editor is not blocked)
await expect(page.getByTestId('tl-license-expired')).not.toBeVisible()
})
})
test.describe('Expired evaluation license', () => {
test('does not render editor for expired evaluation license', async ({ page }) => {
const expiredEvaluationLicenseKey =
'tldraw-/WyJ0ZXN0LWV2YWwtZXhwaXJlZC03ZGF5cyIsWyJsb2NhbGhvc3QiXSwxNiwiMjAyNS0wOC0yMlQwODoyMDoyMC43MjJaIl0=.XIuM3PlrSjs3WMX2tWzgZil/x/TNliCR/NgHtUHcr6nzk2n6S+2ijsd6w+MPP9uEyUdRdg/FQLfcYJ5pqA/G0w=='
const consoleMessages: string[] = []
page.on('console', (msg) => {
consoleMessages.push(msg.text())
})
await setupLicenseTest(page, expiredEvaluationLicenseKey)
await expect(page.getByTestId('tl-license-expired')).toBeAttached()
await expect(page.locator('.tl-canvas')).not.toBeVisible()
const hasExpiredMessage = consoleMessages.some((msg) =>
msg.includes('Your tldraw evaluation license has expired!')
)
expect(hasExpiredMessage).toBe(true)
})
})
test.describe('Expired annual/perpetual license', () => {
test('does not render editor for expired annual license (beyond grace period)', async ({
page,
}) => {
const expiredAnnualLicenseKey =
'tldraw-/WyJ0ZXN0LWV4cGlyZWQtNzAiLFsibG9jYWxob3N0Il0sMSwiMjAyNS0wNi0yMFQwODoxODoyMS4zODRaIl0=.99Ce05q5rnBGhpTzptrTtvmYGjD4EEmWRbcrYqcVFdR1cRbcCzmHNSoGIz1wFWjpZSNQrMZ2ezj5yPALFRjVEw=='
const consoleMessages: string[] = []
page.on('console', (msg) => {
consoleMessages.push(msg.text())
})
await setupLicenseTest(page, expiredAnnualLicenseKey)
await expect(page.getByTestId('tl-license-expired')).toBeAttached()
await expect(page.locator('.tl-canvas')).not.toBeVisible()
const hasExpiredMessage = consoleMessages.some((msg) =>
msg.includes('Your tldraw license has been expired for more than 30 days!')
)
expect(hasExpiredMessage).toBe(true)
const hasAnnualMessage = consoleMessages.some((msg) =>
msg.includes('Please reach out to sales@tldraw.com to renew your license.')
)
expect(hasAnnualMessage).toBe(true)
})
})

View File

@@ -0,0 +1,44 @@
import { expect } from '@playwright/test'
import test from '../fixtures/fixtures'
import { setupOrReset } from '../shared-e2e'
test.describe('help menu', () => {
test.beforeEach(setupOrReset)
test('you can open and close the menu', async ({ mainMenu, page }) => {
const { mainMenuButton, buttons } = mainMenu
const { editSubmenu, viewSubmenu, preferencesSubmenu } = mainMenu.subMenus
const submenus = [
{ name: 'preferences', submenu: preferencesSubmenu, button: buttons.preferences },
{ name: 'view', submenu: viewSubmenu, button: buttons.view },
{ name: 'edit', submenu: editSubmenu, button: buttons.edit },
]
await test.step('open main menu', async () => {
await expect(buttons.edit).toBeHidden()
await mainMenuButton.click()
await expect(buttons.edit).toBeVisible()
})
for (const submenu of submenus) {
await test.step(`hovering opens ${submenu.name} submenu`, async () => {
await expect(submenu.submenu[0]).toBeHidden()
await submenu.button.hover()
await expect(submenu.submenu[0]).toBeVisible()
})
}
await test.step('close main menu', async () => {
// close the menu by clicking the main menu button again
await mainMenuButton.click()
await expect(buttons.edit).toBeHidden()
// open the menu again
await mainMenuButton.click()
// click somewhere on the canvas to close the menu
await page.mouse.click(250, 150)
await expect(buttons.edit).toBeHidden()
})
})
// ...
// More tests here
// ...
})

View File

@@ -0,0 +1,33 @@
import { expect } from '@playwright/test'
import test from '../fixtures/fixtures'
import { setupOrReset } from '../shared-e2e'
test.describe('navigationPanel', () => {
test.beforeEach(setupOrReset)
test('you can open and close the zoom menu', async ({ navigationPanel, isMobile }) => {
// no navigationPanel on mobile
test.skip(isMobile)
const { zoomMenuButton } = navigationPanel
const { zoomIn } = navigationPanel.zoomMenuItems
await expect(zoomIn).toBeHidden()
await zoomMenuButton.click()
await expect(zoomIn).toBeVisible()
await zoomMenuButton.click()
await expect(zoomIn).toBeHidden()
})
test('you can toggle the minimap', async ({ navigationPanel, isMobile }) => {
// no navigationPanel on mobile
test.skip(isMobile)
const { minimap, toggleButton } = navigationPanel
await expect(minimap).toBeHidden()
await toggleButton.click()
await expect(minimap).toBeVisible()
await toggleButton.click()
await expect(minimap).toBeHidden()
})
// ...
// More tests here
// ...
})

View File

@@ -0,0 +1,261 @@
import { Page, expect } from '@playwright/test'
import { Editor, IndexKey } from 'tldraw'
import test from '../fixtures/fixtures'
import { hardResetEditor, setup } from '../shared-e2e'
declare const editor: Editor
// Screenshot-based regression tests for canvas overlay appearance.
//
// Overlays render to a single <canvas class="tl-canvas-overlays"> element, so
// we screenshot the `.tl-canvas` region and let the image compare cover every
// overlay kind (selection foreground, shape handles, brush, snap indicators,
// scribble, etc.) at once.
//
// Tests use direct editor state injection (brush/zoomBrush via
// updateInstanceState, scribbles via ScribbleManager, snap indicators via
// SnapManager.setIndicators) rather than simulating drags. This is what the
// unit tests for each OverlayUtil already do, and it keeps the states frame-
// exact rather than timing-dependent.
async function prepare(page: Page) {
await page.evaluate(() => {
editor.user.updateUserPreferences({ colorScheme: 'light', animationSpeed: 0 })
editor.setCamera({ x: 0, y: 0, z: 1 }, { immediate: true })
editor.updateInstanceState({ isGridMode: false })
})
// Move mouse out of any shape to avoid hover indicators.
await page.mouse.move(0, 0)
}
async function snapshotCanvas(page: Page) {
// Wait for two rAFs so reactive overlay canvases have committed their paint.
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
)
)
// Allow a small tolerance to absorb sub-pixel anti-aliasing variance on
// rotated or curved overlay strokes. A real overlay regression (missing
// handle, wrong color, moved stroke) changes far more than 0.5% of pixels.
// Screenshot capture also waits for the canvas element to become stable,
// which can exceed the global 2s expect timeout on cold preview runs.
await expect(page.locator('.tl-canvas')).toHaveScreenshot({
maxDiffPixelRatio: 0.005,
timeout: 10_000,
})
}
test.describe('Overlay snapshots', () => {
test.skip(
({ isMobile }) => isMobile,
'Overlay snapshots are desktop-only; mobile overlays render differently (coarse-pointer rotate handle, different resize handle visibility).'
)
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)
}
await prepare(page)
})
test('selection foreground: single shape', async ({ page }) => {
await page.evaluate(() => {
editor.createShape({
type: 'geo',
x: 220,
y: 160,
props: { w: 240, h: 160, geo: 'rectangle', dash: 'solid', fill: 'none' },
})
editor.selectAll()
})
await snapshotCanvas(page)
})
test('selection foreground: multiple shapes', async ({ page }) => {
await page.evaluate(() => {
editor.createShapes([
{
type: 'geo',
x: 180,
y: 140,
props: { w: 120, h: 100, geo: 'rectangle', dash: 'solid', fill: 'none' },
},
{
type: 'geo',
x: 360,
y: 200,
props: { w: 140, h: 120, geo: 'ellipse', dash: 'solid', fill: 'none' },
},
])
editor.selectAll()
})
await snapshotCanvas(page)
})
test('selection foreground: rotated shape', async ({ page }) => {
await page.evaluate(() => {
editor.createShape({
type: 'geo',
x: 260,
y: 180,
rotation: Math.PI / 6,
props: { w: 220, h: 140, geo: 'rectangle', dash: 'solid', fill: 'none' },
})
editor.selectAll()
})
await snapshotCanvas(page)
})
test('shape handles: arrow endpoints', async ({ page }) => {
await page.evaluate(() => {
editor.createShape({
type: 'arrow',
x: 220,
y: 220,
props: {
start: { x: 0, y: 0 },
end: { x: 280, y: 0 },
dash: 'solid',
},
})
editor.selectAll()
})
await snapshotCanvas(page)
})
test('shape handles: line with vertices', async ({ page }) => {
await page.evaluate(() => {
editor.createShape({
type: 'line',
x: 200,
y: 260,
props: {
dash: 'solid',
points: {
a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 },
a2: { id: 'a2', index: 'a2' as IndexKey, x: 180, y: -60 },
a3: { id: 'a3', index: 'a3' as IndexKey, x: 360, y: 0 },
},
},
})
editor.selectAll()
})
await snapshotCanvas(page)
})
test('shape handles: line endpoints and hovered center handle', async ({ page }) => {
// Capture the two states a user sees on a selected line: the idle state
// where only the vertex handles at the start and end are visible, and
// the hover state where the midpoint create handle becomes visible
// with its hover halo.
await page.evaluate(() => {
editor.createShape({
type: 'line',
x: 200,
y: 200,
props: {
dash: 'solid',
points: {
a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 },
a2: { id: 'a2', index: 'a2' as IndexKey, x: 280, y: 280 },
},
},
})
editor.selectAll()
})
await snapshotCanvas(page)
// Force-hover the midpoint 'create' handle so the screenshot is frame-
// exact regardless of the browser's pointer coordinate rounding.
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()!
const handles = editor.getShapeHandles(shape)!
const midHandle = handles.find((h) => h.type === 'create')!
editor.overlays.setHoveredOverlay(`handle:${shape.id}:${midHandle.id}`)
})
await snapshotCanvas(page)
})
test('brush overlay', async ({ page }) => {
await page.evaluate(() => {
editor.updateInstanceState({ brush: { x: 180, y: 140, w: 320, h: 200 } })
})
await snapshotCanvas(page)
})
test('zoom brush overlay', async ({ page }) => {
await page.evaluate(() => {
editor.updateInstanceState({ zoomBrush: { x: 180, y: 140, w: 320, h: 200 } })
})
await snapshotCanvas(page)
})
test('snap indicator overlay: point snaps', async ({ page }) => {
await page.evaluate(() => {
editor.createShapes([
{
type: 'geo',
x: 180,
y: 160,
props: { w: 100, h: 100, geo: 'rectangle', dash: 'solid', fill: 'none' },
},
{
type: 'geo',
x: 380,
y: 160,
props: { w: 100, h: 100, geo: 'rectangle', dash: 'solid', fill: 'none' },
},
])
editor.snaps.setIndicators([
{
id: 'p-top',
type: 'points',
points: [
{ x: 230, y: 160 },
{ x: 430, y: 160 },
],
},
{
id: 'p-bottom',
type: 'points',
points: [
{ x: 230, y: 260 },
{ x: 430, y: 260 },
],
},
])
})
await snapshotCanvas(page)
})
test('scribble overlay', async ({ page }) => {
await page.evaluate(async () => {
const item = editor.scribbles.addScribble(
{ color: 'accent', size: 20, opacity: 0.8, taper: true },
'snapshot-scribble'
)
// Build a smooth arc of points. Advance the tick between each
// addPoint so ScribbleManager moves `next` into `scribble.points`.
const pts: Array<[number, number]> = []
for (let i = 0; i <= 20; i++) {
const t = i / 20
const x = 200 + t * 320
const y = 240 + Math.sin(t * Math.PI) * -60
pts.push([x, y])
}
for (const [x, y] of pts) {
editor.scribbles.addPoint(item.id, x, y, 0.5)
editor.scribbles.tick(16)
}
editor.scribbles.complete(item.id)
editor.scribbles.tick(16)
})
await snapshotCanvas(page)
})
})

Some files were not shown because too many files have changed in this diff Show More