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