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
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:
178
apps/examples/e2e/fixtures/analytics-reporter.ts
Normal file
178
apps/examples/e2e/fixtures/analytics-reporter.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { PerformanceTestResult } from './perf-utils'
|
||||
|
||||
export interface AnalyticsConfig {
|
||||
enabled: boolean
|
||||
projectKey?: string
|
||||
apiHost?: string
|
||||
}
|
||||
|
||||
export class PerformanceAnalyticsReporter {
|
||||
private config: AnalyticsConfig
|
||||
|
||||
constructor(config: AnalyticsConfig = { enabled: false }) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
async reportPerformanceMetrics(
|
||||
results: PerformanceTestResult[],
|
||||
testContext: {
|
||||
testName: string
|
||||
timestamp: string
|
||||
gitCommit?: string
|
||||
branch?: string
|
||||
ciEnvironment?: string
|
||||
}
|
||||
): Promise<void> {
|
||||
if (!this.config.enabled) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
'Analytics reporting disabled - would have sent:',
|
||||
results.length,
|
||||
'performance results'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = this.formatPayload(results, testContext)
|
||||
// See https://eu.posthog.com/project/45972/insights/ZdpGyj1r
|
||||
await this.sendToPostHog(payload)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Sent ${results.length} performance metrics to PostHog`)
|
||||
} catch (error) {
|
||||
console.error('Failed to send performance metrics:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private formatPayload(results: PerformanceTestResult[], testContext: any) {
|
||||
return results.map((result) => ({
|
||||
event: 'performance_test_result',
|
||||
properties: {
|
||||
// Test identification
|
||||
test_name: testContext.testName,
|
||||
interaction: result.interaction,
|
||||
timestamp: testContext.timestamp,
|
||||
|
||||
// Environment
|
||||
platform: result.environment.platform,
|
||||
viewport: result.environment.viewport,
|
||||
browser: result.environment.browser,
|
||||
|
||||
// Performance metrics
|
||||
avg_fps: result.metrics.averageFps,
|
||||
min_fps: result.metrics.minFps,
|
||||
max_fps: result.metrics.maxFps,
|
||||
total_frames: result.metrics.totalFrames,
|
||||
duration_seconds: result.metrics.duration,
|
||||
sample_count: result.metrics.samples.length,
|
||||
|
||||
// Comparison results
|
||||
baseline_avg_fps: result.comparison.baseline.avgFps,
|
||||
fps_change_percent: result.comparison.avgFpsChange,
|
||||
is_regression: result.comparison.isRegression,
|
||||
is_improvement: result.comparison.isImprovement,
|
||||
status: result.comparison.status,
|
||||
|
||||
// CI/Git context
|
||||
git_commit: testContext.gitCommit,
|
||||
git_branch: testContext.branch,
|
||||
ci_environment: testContext.ciEnvironment,
|
||||
|
||||
// Additional metadata
|
||||
fps_samples: result.metrics.samples.slice(0, 10), // First 10 samples for analysis
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
private async sendToPostHog(events: any[]): Promise<void> {
|
||||
if (!this.config.projectKey) {
|
||||
throw new Error('PostHog project key not configured')
|
||||
}
|
||||
|
||||
const url = `${this.config.apiHost || 'https://analytics.tldraw.com'}/capture/`
|
||||
|
||||
for (const event of events) {
|
||||
const payload = {
|
||||
api_key: this.config.projectKey,
|
||||
event: event.event,
|
||||
properties: {
|
||||
...event.properties,
|
||||
distinct_id: `e2e-test-${Date.now()}`, // Anonymous test identifier
|
||||
$lib: 'tldraw-e2e-perf',
|
||||
$lib_version: '1.0.0',
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`PostHog API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async reportRegressionAlert(result: PerformanceTestResult, testContext: any): Promise<void> {
|
||||
if (!this.config.enabled || result.comparison.status !== 'fail') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const alertPayload = {
|
||||
event: 'performance_regression_alert',
|
||||
properties: {
|
||||
test_name: testContext.testName,
|
||||
interaction: result.interaction,
|
||||
avg_fps_drop: Math.abs(result.comparison.avgFpsChange),
|
||||
current_fps: result.metrics.averageFps,
|
||||
baseline_fps: result.comparison.baseline.avgFps,
|
||||
platform: result.environment.platform,
|
||||
browser: result.environment.browser,
|
||||
timestamp: testContext.timestamp,
|
||||
git_commit: testContext.gitCommit,
|
||||
git_branch: testContext.branch,
|
||||
alert_message: result.comparison.message,
|
||||
severity: result.comparison.avgFpsChange < -20 ? 'high' : 'medium',
|
||||
},
|
||||
}
|
||||
|
||||
await this.sendToPostHog([alertPayload])
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Sent performance regression alert for ${result.interaction}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to send regression alert:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function to create analytics reporter with environment-based config
|
||||
export function createAnalyticsReporter(): PerformanceAnalyticsReporter {
|
||||
const config: AnalyticsConfig = {
|
||||
enabled: process.env.PERFORMANCE_ANALYTICS_ENABLED === 'true',
|
||||
projectKey: 'phc_i8oKgMzgV38sn3GfjswW9mevQ3gFlo7bJXekZFeDN6',
|
||||
apiHost: process.env.POSTHOG_API_HOST || 'https://analytics.tldraw.com/ingest',
|
||||
}
|
||||
|
||||
return new PerformanceAnalyticsReporter(config)
|
||||
}
|
||||
|
||||
// Helper to collect test context from environment
|
||||
export function getTestContext(testName: string) {
|
||||
return {
|
||||
testName,
|
||||
timestamp: new Date().toISOString(),
|
||||
gitCommit: process.env.GIT_COMMIT || process.env.GITHUB_SHA,
|
||||
branch: process.env.GIT_BRANCH || process.env.GITHUB_REF_NAME,
|
||||
ciEnvironment: process.env.CI
|
||||
? process.env.GITHUB_ACTIONS
|
||||
? 'github-actions'
|
||||
: 'ci'
|
||||
: 'local',
|
||||
}
|
||||
}
|
||||
261
apps/examples/e2e/fixtures/baseline-manager.ts
Normal file
261
apps/examples/e2e/fixtures/baseline-manager.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { FPSMetrics } from './fps-tracker'
|
||||
|
||||
export interface PerformanceBaseline {
|
||||
avgFps: number
|
||||
minFps: number
|
||||
maxFps: number
|
||||
timestamp: string
|
||||
environment: {
|
||||
platform: string
|
||||
viewport: string
|
||||
browser: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface BaselineData {
|
||||
baselines: {
|
||||
[platform: string]: {
|
||||
[interaction: string]: PerformanceBaseline
|
||||
}
|
||||
}
|
||||
metadata: {
|
||||
lastUpdated: string
|
||||
version: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ComparisonResult {
|
||||
interaction: string
|
||||
current: FPSMetrics
|
||||
baseline: PerformanceBaseline
|
||||
avgFpsChange: number // Percentage change
|
||||
minFpsChange: number
|
||||
isRegression: boolean
|
||||
isImprovement: boolean
|
||||
status: 'pass' | 'warning' | 'fail'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface Environment {
|
||||
platform: string
|
||||
viewport: string
|
||||
browser: string
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname)
|
||||
|
||||
export class BaselineManager {
|
||||
private baselineFilePath: string
|
||||
private regressionThreshold = 15 // Percentage
|
||||
private warningThreshold = 10 // Percentage
|
||||
|
||||
constructor(baselineDir = path.join(__dirname, '..', 'baselines')) {
|
||||
// Ensure baseline directory exists
|
||||
if (!fs.existsSync(baselineDir)) {
|
||||
fs.mkdirSync(baselineDir, { recursive: true })
|
||||
}
|
||||
this.baselineFilePath = path.join(baselineDir, 'fps-baselines.json')
|
||||
}
|
||||
|
||||
private loadBaselines(): BaselineData {
|
||||
if (!fs.existsSync(this.baselineFilePath)) {
|
||||
return {
|
||||
baselines: {},
|
||||
metadata: {
|
||||
lastUpdated: new Date().toISOString(),
|
||||
version: '1.0.0',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(this.baselineFilePath, 'utf-8')
|
||||
return JSON.parse(content)
|
||||
} catch (error) {
|
||||
console.warn('Failed to load baseline file, creating new one:', error)
|
||||
return {
|
||||
baselines: {},
|
||||
metadata: {
|
||||
lastUpdated: new Date().toISOString(),
|
||||
version: '1.0.0',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private saveBaselines(data: BaselineData): void {
|
||||
try {
|
||||
const content = JSON.stringify(data, null, 2)
|
||||
fs.writeFileSync(this.baselineFilePath, content, 'utf-8')
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to save baselines: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
private getPlatformKey(env: Environment): string {
|
||||
return `${env.platform}-${env.viewport}`
|
||||
}
|
||||
|
||||
private calculatePercentageChange(current: number, baseline: number): number {
|
||||
if (baseline === 0) return 0
|
||||
return ((current - baseline) / baseline) * 100
|
||||
}
|
||||
|
||||
updateBaseline(
|
||||
interaction: string,
|
||||
metrics: FPSMetrics,
|
||||
environment: Environment,
|
||||
force = false
|
||||
): void {
|
||||
const data = this.loadBaselines()
|
||||
const platformKey = this.getPlatformKey(environment)
|
||||
|
||||
if (!data.baselines[platformKey]) {
|
||||
data.baselines[platformKey] = {}
|
||||
}
|
||||
|
||||
const existingBaseline = data.baselines[platformKey][interaction]
|
||||
const newBaseline: PerformanceBaseline = {
|
||||
avgFps: metrics.averageFps,
|
||||
minFps: metrics.minFps,
|
||||
maxFps: metrics.maxFps,
|
||||
timestamp: new Date().toISOString(),
|
||||
environment,
|
||||
}
|
||||
|
||||
// Only update if it's an improvement, forced, or no existing baseline
|
||||
if (!existingBaseline || force || metrics.averageFps > existingBaseline.avgFps) {
|
||||
data.baselines[platformKey][interaction] = newBaseline
|
||||
data.metadata.lastUpdated = new Date().toISOString()
|
||||
this.saveBaselines(data)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Updated baseline for ${interaction} on ${platformKey}:`, newBaseline)
|
||||
}
|
||||
}
|
||||
|
||||
compareWithBaseline(
|
||||
interaction: string,
|
||||
metrics: FPSMetrics,
|
||||
environment: Environment,
|
||||
autoCreateBaseline = true
|
||||
): ComparisonResult {
|
||||
const data = this.loadBaselines()
|
||||
const platformKey = this.getPlatformKey(environment)
|
||||
const baseline = data.baselines[platformKey]?.[interaction]
|
||||
|
||||
if (!baseline) {
|
||||
if (autoCreateBaseline) {
|
||||
// Automatically create baseline on first run
|
||||
this.updateBaseline(interaction, metrics, environment, true)
|
||||
return {
|
||||
interaction,
|
||||
current: metrics,
|
||||
baseline: {
|
||||
avgFps: metrics.averageFps,
|
||||
minFps: metrics.minFps,
|
||||
maxFps: metrics.maxFps,
|
||||
timestamp: new Date().toISOString(),
|
||||
environment,
|
||||
},
|
||||
avgFpsChange: 0,
|
||||
minFpsChange: 0,
|
||||
isRegression: false,
|
||||
isImprovement: false,
|
||||
status: 'pass',
|
||||
message: `New baseline created for ${interaction} on ${platformKey}. Average FPS: ${metrics.averageFps}`,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
interaction,
|
||||
current: metrics,
|
||||
baseline: {
|
||||
avgFps: 0,
|
||||
minFps: 0,
|
||||
maxFps: 0,
|
||||
timestamp: '',
|
||||
environment,
|
||||
},
|
||||
avgFpsChange: 0,
|
||||
minFpsChange: 0,
|
||||
isRegression: false,
|
||||
isImprovement: false,
|
||||
status: 'warning',
|
||||
message: `No baseline found for ${interaction} on ${platformKey}. Consider setting a baseline first.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const avgFpsChange = this.calculatePercentageChange(metrics.averageFps, baseline.avgFps)
|
||||
const minFpsChange = this.calculatePercentageChange(metrics.minFps, baseline.minFps)
|
||||
|
||||
const isRegression = avgFpsChange < -this.regressionThreshold
|
||||
const isImprovement = avgFpsChange > this.warningThreshold
|
||||
|
||||
let status: 'pass' | 'warning' | 'fail' = 'pass'
|
||||
let message = `Performance stable. Average FPS: ${metrics.averageFps} (${avgFpsChange.toFixed(1)}% change)`
|
||||
|
||||
if (isRegression) {
|
||||
status = 'fail'
|
||||
message = `Performance regression detected! Average FPS dropped by ${Math.abs(avgFpsChange).toFixed(1)}% (${baseline.avgFps} → ${metrics.averageFps})`
|
||||
} else if (isImprovement) {
|
||||
status = 'warning' // Warning to indicate a significant change that might warrant baseline update
|
||||
message = `Performance improvement detected! Average FPS improved by ${avgFpsChange.toFixed(1)}% (${baseline.avgFps} → ${metrics.averageFps}). Consider updating baseline.`
|
||||
}
|
||||
|
||||
return {
|
||||
interaction,
|
||||
current: metrics,
|
||||
baseline,
|
||||
avgFpsChange,
|
||||
minFpsChange,
|
||||
isRegression,
|
||||
isImprovement,
|
||||
status,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
getAllBaselines(): BaselineData {
|
||||
return this.loadBaselines()
|
||||
}
|
||||
|
||||
getBaseline(interaction: string, environment: Environment): PerformanceBaseline | null {
|
||||
const data = this.loadBaselines()
|
||||
const platformKey = this.getPlatformKey(environment)
|
||||
return data.baselines[platformKey]?.[interaction] || null
|
||||
}
|
||||
|
||||
deleteBaseline(interaction: string, environment: Environment): boolean {
|
||||
const data = this.loadBaselines()
|
||||
const platformKey = this.getPlatformKey(environment)
|
||||
|
||||
if (data.baselines[platformKey]?.[interaction]) {
|
||||
delete data.baselines[platformKey][interaction]
|
||||
data.metadata.lastUpdated = new Date().toISOString()
|
||||
this.saveBaselines(data)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
setThresholds(regressionThreshold: number, warningThreshold: number): void {
|
||||
if (regressionThreshold <= 0 || warningThreshold <= 0) {
|
||||
throw new Error('Thresholds must be positive numbers')
|
||||
}
|
||||
if (warningThreshold > regressionThreshold) {
|
||||
throw new Error('Warning threshold must be less than or equal to regression threshold')
|
||||
}
|
||||
|
||||
this.regressionThreshold = regressionThreshold
|
||||
this.warningThreshold = warningThreshold
|
||||
}
|
||||
|
||||
getThresholds(): { regressionThreshold: number; warningThreshold: number } {
|
||||
return {
|
||||
regressionThreshold: this.regressionThreshold,
|
||||
warningThreshold: this.warningThreshold,
|
||||
}
|
||||
}
|
||||
}
|
||||
110
apps/examples/e2e/fixtures/fixtures.ts
Normal file
110
apps/examples/e2e/fixtures/fixtures.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Page, test as base } from '@playwright/test'
|
||||
import { EndToEndApi } from '../../src/misc/EndToEndApi'
|
||||
import { ActionsMenu } from './menus/ActionsMenu'
|
||||
import { HelpMenu } from './menus/HelpMenu'
|
||||
import { MainMenu } from './menus/MainMenu'
|
||||
import { MenuClickCapture } from './menus/MenuClickCapture'
|
||||
import { NavigationPanel } from './menus/NavigationPanel'
|
||||
import { PageMenu } from './menus/PageMenu'
|
||||
import { RichTextToolbar } from './menus/RichTextToolbar'
|
||||
import { StylePanel } from './menus/StylePanel'
|
||||
import { Toolbar } from './menus/Toolbar'
|
||||
|
||||
interface Fixtures {
|
||||
menuClickCapture: MenuClickCapture
|
||||
toolbar: Toolbar
|
||||
richTextToolbar: RichTextToolbar
|
||||
stylePanel: StylePanel
|
||||
actionsMenu: ActionsMenu
|
||||
helpMenu: HelpMenu
|
||||
mainMenu: MainMenu
|
||||
pageMenu: PageMenu
|
||||
navigationPanel: NavigationPanel
|
||||
api: ReturnType<typeof makeApiFixture>
|
||||
isMac: boolean
|
||||
}
|
||||
|
||||
export type ApiFixture = {
|
||||
[K in keyof EndToEndApi]: (
|
||||
...args: Parameters<EndToEndApi[K]>
|
||||
) => Promise<ReturnType<EndToEndApi[K]>>
|
||||
}
|
||||
|
||||
function makeApiFixture(keys: { [K in keyof EndToEndApi]: true }, page: Page): ApiFixture {
|
||||
const result = {} as any
|
||||
|
||||
for (const key of Object.keys(keys)) {
|
||||
result[key] = (...args: any[]) => {
|
||||
return page.evaluate(
|
||||
([key, ...args]) => {
|
||||
return (window as any).tldrawApi[key](...args)
|
||||
},
|
||||
[key, ...args]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const test = base.extend<Fixtures>({
|
||||
menuClickCapture: async ({ page }, testUse) => {
|
||||
const toolbar = new MenuClickCapture(page)
|
||||
await testUse(toolbar)
|
||||
},
|
||||
toolbar: async ({ page }, testUse) => {
|
||||
const toolbar = new Toolbar(page)
|
||||
await testUse(toolbar)
|
||||
},
|
||||
richTextToolbar: async ({ page }, testUse) => {
|
||||
const richTextToolbar = new RichTextToolbar(page)
|
||||
await testUse(richTextToolbar)
|
||||
},
|
||||
stylePanel: async ({ page }, testUse) => {
|
||||
const stylePanel = new StylePanel(page)
|
||||
await testUse(stylePanel)
|
||||
},
|
||||
actionsMenu: async ({ page }, testUse) => {
|
||||
const actionsMenu = new ActionsMenu(page)
|
||||
await testUse(actionsMenu)
|
||||
},
|
||||
helpMenu: async ({ page }, testUse) => {
|
||||
const helpMenu = new HelpMenu(page)
|
||||
await testUse(helpMenu)
|
||||
},
|
||||
mainMenu: async ({ page }, testUse) => {
|
||||
const mainMenu = new MainMenu(page)
|
||||
await testUse(mainMenu)
|
||||
},
|
||||
pageMenu: async ({ page }, testUse) => {
|
||||
const pagemenu = new PageMenu(page)
|
||||
await testUse(pagemenu)
|
||||
},
|
||||
navigationPanel: async ({ page }, testUse) => {
|
||||
const navigationPanel = new NavigationPanel(page)
|
||||
await testUse(navigationPanel)
|
||||
},
|
||||
api: async ({ page }, testUse) => {
|
||||
const api = makeApiFixture(
|
||||
{
|
||||
exportAsSvg: true,
|
||||
exportAsFormat: true,
|
||||
createShapeId: true,
|
||||
resetMockShapeIds: true,
|
||||
createMermaidDiagram: true,
|
||||
checkMermaidReadability: true,
|
||||
toRichText: true,
|
||||
preloadFonts: true,
|
||||
markAllArrowBindings: true,
|
||||
b64VecsEncodePoints: true,
|
||||
},
|
||||
page
|
||||
)
|
||||
await testUse(api)
|
||||
},
|
||||
isMac: async ({ page: _ }, testUse) => {
|
||||
await testUse(process.platform === 'darwin')
|
||||
},
|
||||
})
|
||||
|
||||
export default test
|
||||
155
apps/examples/e2e/fixtures/fps-tracker.ts
Normal file
155
apps/examples/e2e/fixtures/fps-tracker.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
export interface FPSMetrics {
|
||||
averageFps: number
|
||||
minFps: number
|
||||
maxFps: number
|
||||
totalFrames: number
|
||||
duration: number
|
||||
samples: number[]
|
||||
}
|
||||
|
||||
export class E2EFpsTracker {
|
||||
private isTracking = false
|
||||
private startTime = 0
|
||||
private frameCount = 0
|
||||
private fpsSamples: number[] = []
|
||||
private rafId: number | null = null
|
||||
private sampleInterval = 100 // Sample FPS every 100ms
|
||||
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.isTracking) {
|
||||
throw new Error('FPS tracking is already running')
|
||||
}
|
||||
|
||||
this.isTracking = true
|
||||
this.startTime = 0
|
||||
this.frameCount = 0
|
||||
this.fpsSamples = []
|
||||
this.rafId = null
|
||||
|
||||
// Inject FPS tracking script into the page
|
||||
await this.page.evaluate(
|
||||
({ sampleInterval }) => {
|
||||
;(window as any).__e2eFpsTracker = {
|
||||
isTracking: true,
|
||||
startTime: performance.now(),
|
||||
frameCount: 0,
|
||||
fpsSamples: [],
|
||||
lastSampleTime: performance.now(),
|
||||
sampleInterval,
|
||||
|
||||
trackFrame: function () {
|
||||
if (!this.isTracking) return
|
||||
|
||||
this.frameCount++
|
||||
const now = performance.now()
|
||||
|
||||
// Sample FPS at regular intervals
|
||||
if (now - this.lastSampleTime >= this.sampleInterval) {
|
||||
const duration = (now - this.lastSampleTime) / 1000
|
||||
const framesInPeriod = this.frameCount - (this.lastFrameCount || 0)
|
||||
const currentFps = duration > 0 ? framesInPeriod / duration : 0
|
||||
|
||||
this.fpsSamples.push(Math.round(currentFps))
|
||||
this.lastSampleTime = now
|
||||
this.lastFrameCount = this.frameCount
|
||||
}
|
||||
|
||||
// Continue tracking
|
||||
this.rafId = requestAnimationFrame(() => this.trackFrame())
|
||||
},
|
||||
}
|
||||
|
||||
// Start the tracking loop
|
||||
const tracker = (window as any).__e2eFpsTracker
|
||||
tracker.rafId = requestAnimationFrame(() => tracker.trackFrame())
|
||||
},
|
||||
{ sampleInterval: this.sampleInterval }
|
||||
)
|
||||
}
|
||||
|
||||
async stop(): Promise<FPSMetrics> {
|
||||
if (!this.isTracking) {
|
||||
throw new Error('FPS tracking is not running')
|
||||
}
|
||||
|
||||
// Stop tracking and get final measurements
|
||||
const metrics = await this.page.evaluate(() => {
|
||||
const tracker = (window as any).__e2eFpsTracker
|
||||
if (!tracker) {
|
||||
throw new Error('FPS tracker not found on page')
|
||||
}
|
||||
|
||||
tracker.isTracking = false
|
||||
if (tracker.rafId) {
|
||||
cancelAnimationFrame(tracker.rafId)
|
||||
}
|
||||
|
||||
const totalDuration = (performance.now() - tracker.startTime) / 1000
|
||||
const samples = tracker.fpsSamples.filter((fps: number) => fps > 0) // Filter out invalid samples
|
||||
|
||||
if (samples.length === 0) {
|
||||
return {
|
||||
averageFps: 0,
|
||||
minFps: 0,
|
||||
maxFps: 0,
|
||||
totalFrames: tracker.frameCount,
|
||||
duration: totalDuration,
|
||||
samples: [],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
averageFps: Math.round(samples.reduce((a: number, b: number) => a + b, 0) / samples.length),
|
||||
minFps: Math.min(...samples),
|
||||
maxFps: Math.max(...samples),
|
||||
totalFrames: tracker.frameCount,
|
||||
duration: totalDuration,
|
||||
samples: samples,
|
||||
}
|
||||
})
|
||||
|
||||
this.isTracking = false
|
||||
|
||||
// Clean up the tracker from the page
|
||||
await this.page.evaluate(() => {
|
||||
delete (window as any).__e2eFpsTracker
|
||||
})
|
||||
|
||||
return metrics
|
||||
}
|
||||
|
||||
async measureInteraction<T>(
|
||||
interactionFn: () => Promise<T>,
|
||||
options: { warmupMs?: number; measureMs?: number } = {}
|
||||
): Promise<{ result: T; metrics: FPSMetrics }> {
|
||||
const { warmupMs = 500, measureMs = 3000 } = options
|
||||
|
||||
// Warm up period - let the browser settle
|
||||
if (warmupMs > 0) {
|
||||
await this.page.waitForTimeout(warmupMs)
|
||||
}
|
||||
|
||||
await this.start()
|
||||
|
||||
// Execute the interaction
|
||||
const result = await interactionFn()
|
||||
|
||||
// Measure for the specified duration
|
||||
await this.page.waitForTimeout(measureMs)
|
||||
|
||||
const metrics = await this.stop()
|
||||
|
||||
return { result, metrics }
|
||||
}
|
||||
|
||||
async isTrackingActive(): Promise<boolean> {
|
||||
return this.page.evaluate(() => {
|
||||
const tracker = (window as any).__e2eFpsTracker
|
||||
return tracker ? tracker.isTracking : false
|
||||
})
|
||||
}
|
||||
}
|
||||
304
apps/examples/e2e/fixtures/heavy-board-generator.ts
Normal file
304
apps/examples/e2e/fixtures/heavy-board-generator.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
export interface HeavyBoardOptions {
|
||||
shapeCount?: number
|
||||
includeGroups?: boolean
|
||||
includeText?: boolean
|
||||
includeArrows?: boolean
|
||||
canvasWidth?: number
|
||||
canvasHeight?: number
|
||||
seed?: number // For reproducible layouts
|
||||
}
|
||||
|
||||
export class HeavyBoardGenerator {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async generateHeavyBoard(options: HeavyBoardOptions = {}): Promise<void> {
|
||||
const {
|
||||
shapeCount = 300,
|
||||
includeGroups = true,
|
||||
includeText = true,
|
||||
includeArrows = true,
|
||||
canvasWidth = 2000,
|
||||
canvasHeight = 1500,
|
||||
seed = 12345,
|
||||
} = options
|
||||
|
||||
// Clear existing shapes first
|
||||
await this.page.keyboard.press('Control+a')
|
||||
await this.page.keyboard.press('Backspace')
|
||||
|
||||
// Wait for canvas to be ready
|
||||
await this.page.waitForTimeout(100)
|
||||
|
||||
// Generate shapes using editor API injection
|
||||
await this.page.evaluate(
|
||||
({
|
||||
shapeCount,
|
||||
includeGroups: _includeGroups,
|
||||
includeText,
|
||||
includeArrows,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
seed,
|
||||
}) => {
|
||||
const editor = (window as any).editor
|
||||
if (!editor) {
|
||||
throw new Error('Editor not found on window')
|
||||
}
|
||||
|
||||
// Simple seeded random number generator for reproducible tests
|
||||
class SeededRandom {
|
||||
private seed: number
|
||||
constructor(seed: number) {
|
||||
this.seed = seed
|
||||
}
|
||||
|
||||
next(): number {
|
||||
this.seed = (this.seed * 9301 + 49297) % 233280
|
||||
return this.seed / 233280
|
||||
}
|
||||
|
||||
range(min: number, max: number): number {
|
||||
return Math.floor(this.next() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
choice<T>(array: T[]): T {
|
||||
return array[this.range(0, array.length - 1)]
|
||||
}
|
||||
}
|
||||
|
||||
const random = new SeededRandom(seed)
|
||||
const shapes: any[] = []
|
||||
|
||||
// Shape types and their weights
|
||||
const shapeTypes = [
|
||||
{ type: 'geo', weight: 50 }, // rectangles, ellipses, etc.
|
||||
{ type: 'text', weight: includeText ? 25 : 0 },
|
||||
{ type: 'arrow', weight: includeArrows ? 25 : 0 },
|
||||
{ type: 'note', weight: 25 },
|
||||
{ type: 'draw', weight: 25 },
|
||||
].filter((s) => s.weight > 0)
|
||||
|
||||
// Generate base shapes
|
||||
for (let i = 0; i < shapeCount; i++) {
|
||||
const x = random.range(0, canvasWidth)
|
||||
const y = random.range(0, canvasHeight)
|
||||
const width = random.range(50, 200)
|
||||
const height = random.range(30, 150)
|
||||
|
||||
// Pick shape type based on weights
|
||||
let weightSum = 0
|
||||
const targetWeight = random.next() * shapeTypes.reduce((sum, s) => sum + s.weight, 0)
|
||||
const shapeType =
|
||||
shapeTypes.find((s) => (weightSum += s.weight) >= targetWeight)?.type || 'geo'
|
||||
|
||||
let shape: any
|
||||
|
||||
switch (shapeType) {
|
||||
case 'geo': {
|
||||
const geoTypes = ['rectangle', 'ellipse', 'triangle', 'diamond', 'hexagon', 'star']
|
||||
shape = {
|
||||
id: `shape:${i}`,
|
||||
type: 'geo',
|
||||
x,
|
||||
y,
|
||||
props: {
|
||||
w: width,
|
||||
h: height,
|
||||
geo: random.choice(geoTypes),
|
||||
color: random.choice([
|
||||
'black',
|
||||
'blue',
|
||||
'green',
|
||||
'red',
|
||||
'yellow',
|
||||
'orange',
|
||||
'violet',
|
||||
]),
|
||||
fill: random.choice(['none', 'semi', 'solid']),
|
||||
size: random.choice(['s', 'm', 'l', 'xl']),
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'draw': {
|
||||
// Create a simple drawn shape with some points
|
||||
const numPoints = random.range(3, 8)
|
||||
const points: Array<{ x: number; y: number; z: number }> = []
|
||||
for (let p = 0; p < numPoints; p++) {
|
||||
points.push({
|
||||
x: random.range(0, width),
|
||||
y: random.range(0, height),
|
||||
z: 0.5,
|
||||
})
|
||||
}
|
||||
|
||||
// Access b64Vecs from the window.tldrawApi object
|
||||
const tldrawApi = (window as any).tldrawApi
|
||||
const b64VecsEncodePoints = tldrawApi.b64VecsEncodePoints
|
||||
|
||||
shape = {
|
||||
id: `shape:${i}`,
|
||||
type: 'draw',
|
||||
x,
|
||||
y,
|
||||
props: {
|
||||
segments: [
|
||||
{
|
||||
type: 'free',
|
||||
path: b64VecsEncodePoints(points),
|
||||
},
|
||||
],
|
||||
color: random.choice(['black', 'blue', 'green', 'red']),
|
||||
size: random.choice(['s', 'm', 'l']),
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'text': {
|
||||
const tldrawApi = (window as any).tldrawApi
|
||||
const textSamples = [
|
||||
'Performance Test',
|
||||
'Lorem ipsum dolor sit amet',
|
||||
'Heavy Board Test',
|
||||
'Sample Text ' + i,
|
||||
'Multi-line\nText Content',
|
||||
'Longer text content for testing purposes',
|
||||
]
|
||||
shape = {
|
||||
id: `shape:${i}`,
|
||||
type: 'text',
|
||||
x,
|
||||
y,
|
||||
props: {
|
||||
richText: tldrawApi.toRichText(random.choice(textSamples)),
|
||||
color: random.choice(['black', 'blue', 'green', 'red']),
|
||||
size: random.choice(['s', 'm', 'l', 'xl']),
|
||||
font: 'draw',
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'arrow': {
|
||||
const endX = x + random.range(-200, 200)
|
||||
const endY = y + random.range(-200, 200)
|
||||
shape = {
|
||||
id: `shape:${i}`,
|
||||
type: 'arrow',
|
||||
x,
|
||||
y,
|
||||
props: {
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: endX - x, y: endY - y },
|
||||
color: random.choice(['black', 'blue', 'green', 'red']),
|
||||
size: random.choice(['s', 'm', 'l']),
|
||||
arrowheadStart: random.choice(['none', 'arrow', 'triangle']),
|
||||
arrowheadEnd: random.choice(['arrow', 'triangle', 'dot']),
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'note': {
|
||||
const tldrawApi = (window as any).tldrawApi
|
||||
shape = {
|
||||
id: `shape:${i}`,
|
||||
type: 'note',
|
||||
x,
|
||||
y,
|
||||
props: {
|
||||
richText: tldrawApi.toRichText(`Note ${i}\nPerformance testing note`),
|
||||
color: random.choice(['black', 'blue', 'green', 'red', 'yellow']),
|
||||
size: random.choice(['s', 'm', 'l']),
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (shape) {
|
||||
shapes.push(shape)
|
||||
}
|
||||
}
|
||||
|
||||
// Create all shapes at once
|
||||
editor.createShapes(shapes)
|
||||
|
||||
// Skip grouping for now - will add back later when debugging shape creation issues
|
||||
// TODO: Re-enable grouping once basic shape generation is working
|
||||
|
||||
// Zoom to fit all content
|
||||
editor.zoomToFit()
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Generated heavy board with ${shapes.length} shapes`)
|
||||
},
|
||||
{ shapeCount, includeGroups, includeText, includeArrows, canvasWidth, canvasHeight, seed }
|
||||
)
|
||||
|
||||
// Wait for rendering to settle
|
||||
await this.page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
async setupStressTestBoard(shapeCount: number): Promise<void> {
|
||||
// Pre-configured stress test with maximum complexity
|
||||
await this.generateHeavyBoard({
|
||||
shapeCount,
|
||||
includeGroups: true,
|
||||
includeText: true,
|
||||
includeArrows: true,
|
||||
canvasWidth: 3000,
|
||||
canvasHeight: 2500,
|
||||
seed: 42, // Fixed seed for consistent tests
|
||||
})
|
||||
}
|
||||
|
||||
async getShapeCount(): Promise<number> {
|
||||
return await this.page.evaluate(() => {
|
||||
const editor = (window as any).editor
|
||||
return editor ? editor.getCurrentPageShapes().length : 0
|
||||
})
|
||||
}
|
||||
|
||||
async selectRandomShapes(count: number, seed: number = 12345): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
({ count, seed }) => {
|
||||
const editor = (window as any).editor
|
||||
if (!editor) return
|
||||
|
||||
// Simple seeded random number generator for reproducible tests
|
||||
class SeededRandom {
|
||||
private seed: number
|
||||
constructor(seed: number) {
|
||||
this.seed = seed
|
||||
}
|
||||
|
||||
next(): number {
|
||||
this.seed = (this.seed * 9301 + 49297) % 233280
|
||||
return this.seed / 233280
|
||||
}
|
||||
|
||||
range(min: number, max: number): number {
|
||||
return Math.floor(this.next() * (max - min + 1)) + min
|
||||
}
|
||||
}
|
||||
|
||||
const random = new SeededRandom(seed)
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
const selectedCount = Math.min(count, allShapes.length)
|
||||
|
||||
// Select random shapes using seeded random
|
||||
const shuffled = allShapes.sort(() => 0.5 - random.next())
|
||||
const selected = shuffled.slice(0, selectedCount)
|
||||
const shapeIds = selected.map((shape: any) => shape.id)
|
||||
|
||||
editor.setSelectedShapes(shapeIds)
|
||||
},
|
||||
{ count, seed }
|
||||
)
|
||||
}
|
||||
}
|
||||
42
apps/examples/e2e/fixtures/menus/ActionsMenu.ts
Normal file
42
apps/examples/e2e/fixtures/menus/ActionsMenu.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ActionsMenu {
|
||||
readonly quickActions: { [key: string]: Locator }
|
||||
readonly mainActions: { [key: string]: Locator }
|
||||
readonly actionsMenuButton: Locator
|
||||
readonly actionsMenuContent: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.actionsMenuButton = this.page.getByTestId('actions-menu.button')
|
||||
this.actionsMenuContent = this.page.getByTestId('actions-menu.content')
|
||||
this.quickActions = {
|
||||
undo: this.page.getByTestId('quick-actions.undo'),
|
||||
redo: this.page.getByTestId('quick-actions.redo'),
|
||||
delete: this.page.getByTestId('quick-actions.delete'),
|
||||
duplicate: this.page.getByTestId('quick-actions.duplicate'),
|
||||
}
|
||||
this.mainActions = {
|
||||
alignLeft: this.page.getByTestId('actions-menu.align-left'),
|
||||
alignCenterHorizontal: this.page.getByTestId('actions-menu.align-center-horizontal'),
|
||||
alignRight: this.page.getByTestId('actions-menu.align-right'),
|
||||
stretchHorizontal: this.page.getByTestId('actions-menu.stretch-horizontal'),
|
||||
alignTop: this.page.getByTestId('actions-menu.align-top'),
|
||||
alignCenterVertical: this.page.getByTestId('actions-menu.align-center-vertical'),
|
||||
alignBottom: this.page.getByTestId('actions-menu.align-bottom'),
|
||||
stretchVertical: this.page.getByTestId('actions-menu.stretch-vertical'),
|
||||
distributeHorizontal: this.page.getByTestId('actions-menu.distribute-horizontal'),
|
||||
distributeVertical: this.page.getByTestId('actions-menu.distribute-vertical'),
|
||||
stackHorizontal: this.page.getByTestId('actions-menu.stack-horizontal'),
|
||||
stackVertical: this.page.getByTestId('actions-menu.stack-vertical'),
|
||||
sendToBack: this.page.getByTestId('actions-menu.send-to-back'),
|
||||
sendBackward: this.page.getByTestId('actions-menu.send-backward'),
|
||||
bringForward: this.page.getByTestId('actions-menu.bring-forward'),
|
||||
bringToFront: this.page.getByTestId('actions-menu.bring-to-front'),
|
||||
rotateCCW: this.page.getByTestId('actions-menu.rotate-ccw'),
|
||||
rotateCW: this.page.getByTestId('actions-menu.rotate-cw'),
|
||||
editLink: this.page.getByTestId('actions-menu.edit-link'),
|
||||
group: this.page.getByTestId('actions-menu.group'),
|
||||
}
|
||||
}
|
||||
}
|
||||
34
apps/examples/e2e/fixtures/menus/HelpMenu.ts
Normal file
34
apps/examples/e2e/fixtures/menus/HelpMenu.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class HelpMenu {
|
||||
readonly helpMenuButton: Locator
|
||||
readonly languagesButton: Locator
|
||||
readonly languagesContent: Locator
|
||||
readonly keyboardShortcutsMenu: KeyboardShortcutsMenu
|
||||
readonly linkGroup: { [key: string]: Locator }
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.helpMenuButton = this.page.getByTestId('help-menu.button')
|
||||
this.languagesButton = this.page.getByTestId('help-menu-sub.language-button')
|
||||
this.languagesContent = this.page.getByTestId('help-menu-sub.language-content')
|
||||
this.keyboardShortcutsMenu = new KeyboardShortcutsMenu(this.page)
|
||||
this.linkGroup = {
|
||||
github: this.page.getByTestId('help-menu.github'),
|
||||
twitter: this.page.getByTestId('help-menu.twitter'),
|
||||
discord: this.page.getByTestId('help-menu.discord'),
|
||||
about: this.page.getByTestId('help-menu.about'),
|
||||
}
|
||||
}
|
||||
}
|
||||
class KeyboardShortcutsMenu {
|
||||
readonly heading: Locator
|
||||
readonly button: Locator
|
||||
readonly closeButton: Locator
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.heading = this.page.getByRole('dialog').getByText('Keyboard shortcuts')
|
||||
this.button = this.page.getByTestId('help-menu.keyboard-shortcuts-button')
|
||||
this.closeButton = this.page.getByRole('dialog').getByTestId('dialog.close')
|
||||
}
|
||||
}
|
||||
62
apps/examples/e2e/fixtures/menus/MainMenu.ts
Normal file
62
apps/examples/e2e/fixtures/menus/MainMenu.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class MainMenu {
|
||||
readonly mainMenuButton: Locator
|
||||
readonly buttons: { [key: string]: Locator }
|
||||
readonly subMenus: { [key: string]: Locator[] }
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.mainMenuButton = this.page.getByTestId('main-menu.button')
|
||||
this.buttons = {
|
||||
edit: this.page.getByRole('menuitem').getByText('Edit'),
|
||||
object: this.page.getByRole('menuitem').getByText('Object'),
|
||||
view: this.page.getByRole('menuitem').getByText('View'),
|
||||
insertEmbed: this.page.getByRole('menuitem').getByText('Insert Embed'),
|
||||
uploadMedia: this.page.getByRole('menuitem').getByText('Upload Media'),
|
||||
preferences: this.page.getByRole('menuitem').getByText('Preferences'),
|
||||
}
|
||||
this.subMenus = {
|
||||
editSubmenu: [
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Undo'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Redo'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Cut'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Copy'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Copy as'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Duplicate'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Paste'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Delete'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Select all'),
|
||||
],
|
||||
objectSubmenu: [
|
||||
this.page.getByRole('menuitem').getByText('Export as'),
|
||||
this.page.getByRole('menuitem').getByText('Group'),
|
||||
this.page.getByRole('menuitem').getByText('Ungroup'),
|
||||
this.page.getByRole('menuitem').getByText('Remove frame'),
|
||||
this.page.getByRole('menuitem').getByText('Fit to content'),
|
||||
this.page.getByRole('menuitem').getByText('Toggle auto size'),
|
||||
this.page.getByRole('menuitem').getByText('Edit link'),
|
||||
this.page.getByRole('menuitem').getByText('Convert to Bookmark'),
|
||||
this.page.getByRole('menuitem').getByText('Convert to Embed'),
|
||||
this.page.getByRole('menuitem').getByText('Toggle locked'),
|
||||
this.page.getByRole('menuitem').getByText('Unlock all'),
|
||||
],
|
||||
viewSubmenu: [
|
||||
this.page.getByTestId('main-menu-sub.view-content').getByText('Zoom in'),
|
||||
this.page.getByTestId('main-menu-sub.view-content').getByText('Zoom out'),
|
||||
this.page.getByTestId('main-menu-sub.view-content').getByText('Zoom to 100%'),
|
||||
this.page.getByTestId('main-menu-sub.view-content').getByText('Zoom to fit'),
|
||||
this.page.getByTestId('main-menu-sub.view-content').getByText('Zoom to selection'),
|
||||
],
|
||||
preferencesSubmenu: [
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Always snap'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Tool lock'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Show grid'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Dark mode'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Focus mode'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Edge scrolling'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Reduce motion'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Language'),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
10
apps/examples/e2e/fixtures/menus/MenuClickCapture.ts
Normal file
10
apps/examples/e2e/fixtures/menus/MenuClickCapture.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class MenuClickCapture {
|
||||
readonly content: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.content = this.page.getByTestId('menu-click-capture.content')
|
||||
}
|
||||
}
|
||||
21
apps/examples/e2e/fixtures/menus/NavigationPanel.ts
Normal file
21
apps/examples/e2e/fixtures/menus/NavigationPanel.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class NavigationPanel {
|
||||
readonly minimap: Locator
|
||||
readonly zoomMenuButton: Locator
|
||||
readonly toggleButton: Locator
|
||||
readonly zoomMenuItems: { [key: string]: Locator }
|
||||
constructor(private readonly page: Page) {
|
||||
this.page = page
|
||||
this.minimap = page.getByTestId('minimap.canvas')
|
||||
this.zoomMenuButton = page.getByTestId('minimap.zoom-menu-button')
|
||||
this.toggleButton = page.getByTestId('minimap.toggle-button')
|
||||
this.zoomMenuItems = {
|
||||
zoomIn: page.getByRole('menuitem').getByText('Zoom in'),
|
||||
zoomOut: page.getByRole('menuitem').getByText('Zoom out'),
|
||||
zoomToHundred: page.getByRole('menuitem').getByText('Zoom to 100%'),
|
||||
zoomToFit: page.getByRole('menuitem').getByText('Zoom to fit'),
|
||||
zoomToSelection: page.getByRole('menuitem').getByText('Zoom to selection'),
|
||||
}
|
||||
}
|
||||
}
|
||||
27
apps/examples/e2e/fixtures/menus/PageMenu.ts
Normal file
27
apps/examples/e2e/fixtures/menus/PageMenu.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class PageMenu {
|
||||
readonly pagemenuButton: Locator
|
||||
readonly header: Locator
|
||||
readonly createButton: Locator
|
||||
readonly pageList: Locator
|
||||
readonly pageItems: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.pagemenuButton = this.page.getByTestId('page-menu.button')
|
||||
// The popover no longer has a title row — use the list as the "is the menu open" anchor.
|
||||
this.header = this.page.getByTestId('page-menu.list')
|
||||
this.createButton = this.page.getByTestId('page-menu.create')
|
||||
this.pageList = this.page.getByTestId('page-menu.list')
|
||||
this.pageItems = this.page.getByTestId('page-menu.item')
|
||||
}
|
||||
|
||||
async getPageItem(index: number) {
|
||||
return this.pageItems.nth(index)
|
||||
}
|
||||
|
||||
getPageItemByName(name: string) {
|
||||
return this.page.getByTestId('page-menu.item').filter({ hasText: name })
|
||||
}
|
||||
}
|
||||
60
apps/examples/e2e/fixtures/menus/RichTextToolbar.ts
Normal file
60
apps/examples/e2e/fixtures/menus/RichTextToolbar.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Locator, Page, expect } from '@playwright/test'
|
||||
|
||||
export class RichTextToolbar {
|
||||
container: Locator
|
||||
readonly tools: { [key: string]: Locator }
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.container = this.page.getByTestId('contextual-toolbar')
|
||||
this.tools = {
|
||||
bold: this.page.getByTestId('rich-text.bold'),
|
||||
italic: this.page.getByTestId('rich-text.italic'),
|
||||
code: this.page.getByTestId('rich-text.code'),
|
||||
strike: this.page.getByTestId('rich-text.strike'),
|
||||
link: this.page.getByTestId('rich-text.link'),
|
||||
bulletList: this.page.getByTestId('rich-text.bulletList'),
|
||||
highlight: this.page.getByTestId('rich-text.highlight'),
|
||||
linkRemove: this.page.getByTestId('rich-text.link-remove'),
|
||||
heading: this.page.getByTestId('rich-text.heading'),
|
||||
}
|
||||
}
|
||||
|
||||
async clickTool(tool: Locator) {
|
||||
await tool.click()
|
||||
}
|
||||
async isSelected(tool: Locator) {
|
||||
// pseudo elements aren't exposed to the DOM, but we can check the color as a proxy
|
||||
const expectedColor = 'rgb(46, 46, 46)'
|
||||
await expect(tool).toHaveCSS('color', expectedColor)
|
||||
}
|
||||
async isNotSelected(tool: Locator) {
|
||||
const expectedColor = 'rgb(255, 255, 255)'
|
||||
await expect(tool).toHaveCSS('color', expectedColor)
|
||||
}
|
||||
|
||||
async getSelectionBounds() {
|
||||
return this.page.evaluate(() => {
|
||||
const selection = window.getSelection()
|
||||
|
||||
// If there are no selections, don't return a box
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return
|
||||
|
||||
// Get a common box from all of the ranges' screen rects
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
for (let i = 0; i < selection.rangeCount; i++) {
|
||||
const range = selection.getRangeAt(i)
|
||||
const box = range.getBoundingClientRect()
|
||||
minX = Math.min(minX, box.left)
|
||||
minY = Math.min(minY, box.top)
|
||||
maxX = Math.max(maxX, box.right)
|
||||
maxY = Math.max(maxY, box.bottom)
|
||||
}
|
||||
|
||||
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }
|
||||
})
|
||||
}
|
||||
}
|
||||
88
apps/examples/e2e/fixtures/menus/StylePanel.ts
Normal file
88
apps/examples/e2e/fixtures/menus/StylePanel.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Locator, Page, expect } from '@playwright/test'
|
||||
|
||||
export class StylePanel {
|
||||
readonly stylesArray: string[]
|
||||
readonly colors: { [key: string]: Locator }
|
||||
readonly fill: { [key: string]: Locator }
|
||||
readonly fillExtra: Locator
|
||||
readonly dash: { [key: string]: Locator }
|
||||
readonly size: { [key: string]: Locator }
|
||||
readonly font: { [key: string]: Locator }
|
||||
readonly align: { [key: string]: Locator }
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.stylesArray = [
|
||||
'style.color',
|
||||
'style.opacity',
|
||||
'style.fill',
|
||||
'style.dash',
|
||||
'style.size',
|
||||
'style.font',
|
||||
'style.align',
|
||||
]
|
||||
this.colors = {
|
||||
black: this.page.getByTestId('style.color.black'),
|
||||
grey: this.page.getByTestId('style.color.grey'),
|
||||
lightViolet: this.page.getByTestId('style.color.light-violet'),
|
||||
violet: this.page.getByTestId('style.color.violet'),
|
||||
blue: this.page.getByTestId('style.color.blue'),
|
||||
lightBlue: this.page.getByTestId('style.color.light-blue'),
|
||||
yellow: this.page.getByTestId('style.color.yellow'),
|
||||
orange: this.page.getByTestId('style.color.orange'),
|
||||
green: this.page.getByTestId('style.color.green'),
|
||||
lightGreen: this.page.getByTestId('style.color.light-green'),
|
||||
lightRed: this.page.getByTestId('style.color.light-red'),
|
||||
red: this.page.getByTestId('style.color.red'),
|
||||
}
|
||||
this.fill = {
|
||||
none: this.page.getByTestId('style.fill.none'),
|
||||
semi: this.page.getByTestId('style.fill.semi'),
|
||||
solid: this.page.getByTestId('style.fill.solid'),
|
||||
}
|
||||
this.fillExtra = this.page.getByTestId('style.fill-extra')
|
||||
this.dash = {
|
||||
draw: this.page.getByTestId('style.dash.draw'),
|
||||
dashed: this.page.getByTestId('style.dash.dashed'),
|
||||
dotted: this.page.getByTestId('style.dash.dotted'),
|
||||
solid: this.page.getByTestId('style.dash.solid'),
|
||||
}
|
||||
this.size = {
|
||||
s: this.page.getByTestId('style.size.s'),
|
||||
m: this.page.getByTestId('style.size.m'),
|
||||
l: this.page.getByTestId('style.size.l'),
|
||||
xl: this.page.getByTestId('style.size.xl'),
|
||||
}
|
||||
this.font = {
|
||||
draw: this.page.getByTestId('style.font.draw'),
|
||||
sans: this.page.getByTestId('style.font.sans'),
|
||||
serif: this.page.getByTestId('style.font.serif'),
|
||||
mono: this.page.getByTestId('style.font.mono'),
|
||||
}
|
||||
|
||||
this.align = {
|
||||
start: this.page.getByTestId('style.align.start'),
|
||||
middle: this.page.getByTestId('style.align.middle'),
|
||||
end: this.page.getByTestId('style.align.end'),
|
||||
}
|
||||
}
|
||||
async getAfterElementStyle(style: Locator, property: string) {
|
||||
const getStyle = (element: Element, property: string) =>
|
||||
window.getComputedStyle(element, '::after').getPropertyValue(property)
|
||||
return await style.evaluate(getStyle, property)
|
||||
}
|
||||
|
||||
async isActive(style: Locator) {
|
||||
const backgroundColor = await this.getAfterElementStyle(style, 'background-color')
|
||||
return expect(backgroundColor).toBe('rgba(0, 0, 0, 0.055)')
|
||||
}
|
||||
|
||||
async isInactive(style: Locator) {
|
||||
const backgroundColor = await this.getAfterElementStyle(style, 'background-color')
|
||||
// The color is different on mobile
|
||||
return expect(['rgba(0, 0, 0, 0.043)', 'rgba(0, 0, 0, 0)']).toContain(backgroundColor)
|
||||
}
|
||||
getElement() {
|
||||
return this.page.getByTestId('style.panel')
|
||||
}
|
||||
}
|
||||
45
apps/examples/e2e/fixtures/menus/Toolbar.ts
Normal file
45
apps/examples/e2e/fixtures/menus/Toolbar.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Locator, Page, expect } from '@playwright/test'
|
||||
|
||||
export class Toolbar {
|
||||
readonly toolLock: Locator
|
||||
readonly moreToolsButton: Locator
|
||||
readonly moreToolsPopover: Locator
|
||||
readonly mobileStylesButton: Locator
|
||||
readonly tools: { [key: string]: Locator }
|
||||
readonly popOverTools: { [key: string]: Locator }
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.toolLock = this.page.getByTestId('tool-lock')
|
||||
this.moreToolsButton = this.page.getByTestId('tools.more-button')
|
||||
this.moreToolsPopover = this.page.getByTestId('tools.more-content')
|
||||
this.mobileStylesButton = this.page.getByTestId('mobile-styles.button')
|
||||
this.tools = {
|
||||
select: this.page.getByTestId('tools.select'),
|
||||
draw: this.page.getByTestId('tools.draw'),
|
||||
text: this.page.getByTestId('tools.text'),
|
||||
arrow: this.page.getByTestId('tools.arrow'),
|
||||
cloud: this.page.getByTestId('tools.cloud'),
|
||||
eraser: this.page.getByTestId('tools.eraser'),
|
||||
rectangle: this.page.getByTestId('tools.rectangle'),
|
||||
hand: this.page.getByTestId('tools.hand'),
|
||||
}
|
||||
this.popOverTools = {
|
||||
popoverCloud: this.page.getByTestId('tools.more.cloud'),
|
||||
popoverFrame: this.page.getByTestId('tools.more.frame'),
|
||||
popoverRectangle: this.page.getByTestId('tools.more.rectangle'),
|
||||
}
|
||||
}
|
||||
async clickTool(tool: Locator) {
|
||||
await tool.click()
|
||||
}
|
||||
async isSelected(tool: Locator) {
|
||||
// pseudo elements aren't exposed to the DOM, but we can check the color as a proxy
|
||||
const expectedColor = 'rgb(255, 255, 255)'
|
||||
await expect(tool).toHaveCSS('color', expectedColor)
|
||||
}
|
||||
async isNotSelected(tool: Locator) {
|
||||
const expectedColor = 'rgb(46, 46, 46)'
|
||||
await expect(tool).toHaveCSS('color', expectedColor)
|
||||
}
|
||||
}
|
||||
645
apps/examples/e2e/fixtures/perf-utils.ts
Normal file
645
apps/examples/e2e/fixtures/perf-utils.ts
Normal file
@@ -0,0 +1,645 @@
|
||||
import { Page, PlaywrightTestArgs } from '@playwright/test'
|
||||
import { Editor } from 'tldraw'
|
||||
import { BaselineManager, ComparisonResult, Environment } from './baseline-manager'
|
||||
import { E2EFpsTracker, FPSMetrics } from './fps-tracker'
|
||||
import { HeavyBoardGenerator } from './heavy-board-generator'
|
||||
|
||||
export interface PerformanceTestResult {
|
||||
interaction: string
|
||||
metrics: FPSMetrics
|
||||
comparison: ComparisonResult
|
||||
environment: Environment
|
||||
}
|
||||
|
||||
export interface InteractionTestOptions {
|
||||
warmupMs?: number
|
||||
measureMs?: number
|
||||
expectMinFps?: number
|
||||
}
|
||||
|
||||
export class PerformanceTestSuite {
|
||||
private fpsTracker: E2EFpsTracker
|
||||
private baselineManager: BaselineManager
|
||||
private boardGenerator: HeavyBoardGenerator
|
||||
private environment: Environment
|
||||
|
||||
constructor(page: Page, browserName: string) {
|
||||
this.fpsTracker = new E2EFpsTracker(page)
|
||||
this.baselineManager = new BaselineManager()
|
||||
this.boardGenerator = new HeavyBoardGenerator(page)
|
||||
|
||||
// Detect environment
|
||||
this.environment = {
|
||||
platform: process.platform,
|
||||
viewport: '1920x1080', // Will be updated based on actual viewport
|
||||
browser: browserName,
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(page: Page): Promise<void> {
|
||||
// Update viewport information
|
||||
const viewport = page.viewportSize()
|
||||
if (viewport) {
|
||||
this.environment.viewport = `${viewport.width}x${viewport.height}`
|
||||
}
|
||||
|
||||
// Set up the page for performance testing
|
||||
await page.goto('http://localhost:5420/end-to-end')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
|
||||
// Disable animations for consistent testing
|
||||
await page.evaluate(() => {
|
||||
const editor: Editor = (window as any).editor
|
||||
if (editor) {
|
||||
editor.user.updateUserPreferences({ animationSpeed: 0 })
|
||||
}
|
||||
})
|
||||
|
||||
// Let the editor settle
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
async setupHeavyBoard(shapeCount = 200): Promise<void> {
|
||||
await this.boardGenerator.setupStressTestBoard(shapeCount)
|
||||
|
||||
// Ensure the board is fully rendered
|
||||
await this.waitForStableFramerate()
|
||||
}
|
||||
|
||||
private async waitForStableFramerate(maxWaitMs = 3000): Promise<void> {
|
||||
// Wait for the framerate to stabilize after board generation
|
||||
const startTime = Date.now()
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
// Check if we're getting consistent frame times
|
||||
const isStable = await this.checkFramerateStability()
|
||||
if (isStable) break
|
||||
}
|
||||
}
|
||||
|
||||
private async checkFramerateStability(): Promise<boolean> {
|
||||
// Simple heuristic: check if the page has settled
|
||||
return true // For now, just return true. Could be enhanced with actual stability detection
|
||||
}
|
||||
|
||||
async testShapeRotation(options: InteractionTestOptions = {}): Promise<PerformanceTestResult> {
|
||||
const { warmupMs = 500, measureMs = 10000, expectMinFps } = options // 10 seconds for lots of rotation
|
||||
|
||||
const { metrics } = await this.fpsTracker.measureInteraction(
|
||||
async () => {
|
||||
const page = this.getPage()
|
||||
|
||||
// Select all shapes for rotation
|
||||
await page.keyboard.press('Control+a')
|
||||
|
||||
// Wait a moment for selection
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
// Perform continuous rotations using the editor API
|
||||
await page.evaluate(() => {
|
||||
const editor: Editor = (window as any).editor
|
||||
if (!editor) return
|
||||
|
||||
// Rotate shapes continuously during measurement period
|
||||
// 10 seconds at 60fps with 5° steps = ~8.7 rotations total!
|
||||
let totalRotations = 0
|
||||
const rotateStep = Math.PI / 36 // 5 degrees per step
|
||||
const degreesPerStep = 5
|
||||
|
||||
const rotateInterval = setInterval(() => {
|
||||
const selectedShapes = editor.getSelectedShapes()
|
||||
if (selectedShapes.length > 0) {
|
||||
editor.rotateShapesBy(selectedShapes, rotateStep)
|
||||
totalRotations += degreesPerStep / 360 // Track full rotations
|
||||
;(window as any).__totalRotations = totalRotations
|
||||
}
|
||||
}, 16) // ~60fps rotation updates
|
||||
|
||||
// Store interval ID and stats so we can clean it up
|
||||
;(window as any).__rotationInterval = rotateInterval
|
||||
})
|
||||
},
|
||||
{ warmupMs, measureMs }
|
||||
)
|
||||
|
||||
// Clean up and clear selection
|
||||
const page = this.getPage()
|
||||
|
||||
// Stop the rotation interval and get stats
|
||||
const rotationStats = await page.evaluate(() => {
|
||||
const interval = (window as any).__rotationInterval
|
||||
const totalRotations = (window as any).__totalRotations || 0
|
||||
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
delete (window as any).__rotationInterval
|
||||
delete (window as any).__totalRotations
|
||||
}
|
||||
|
||||
return { totalRotations: Math.round(totalRotations * 10) / 10 } // Round to 1 decimal
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`🔄 Completed ${rotationStats.totalRotations} rotations during measurement`)
|
||||
|
||||
// Clear selection
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
return this.finalizeTestResult('rotate_shapes', metrics, expectMinFps)
|
||||
}
|
||||
|
||||
async testShapeDragging(options: InteractionTestOptions = {}): Promise<PerformanceTestResult> {
|
||||
const { warmupMs = 500, measureMs = 3000, expectMinFps } = options
|
||||
|
||||
// Select shapes for dragging
|
||||
await this.boardGenerator.selectRandomShapes(5)
|
||||
|
||||
const { metrics } = await this.fpsTracker.measureInteraction(
|
||||
async () => {
|
||||
const page = this.getPage()
|
||||
|
||||
// Select all shapes for rotation
|
||||
await page.keyboard.press('Control+a')
|
||||
|
||||
// Drag shapes around the canvas
|
||||
const startX = 400
|
||||
const startY = 400
|
||||
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
|
||||
// Drag in various directions
|
||||
const movements = [
|
||||
[600, 300],
|
||||
[800, 500],
|
||||
[500, 700],
|
||||
[300, 600],
|
||||
[400, 400],
|
||||
[500, 500],
|
||||
[700, 700],
|
||||
[900, 900],
|
||||
[1100, 1100],
|
||||
[1300, 1300],
|
||||
[200, 200],
|
||||
[324, 324],
|
||||
[448, 448],
|
||||
[572, 572],
|
||||
[696, 696],
|
||||
[820, 820],
|
||||
]
|
||||
|
||||
for (const [x, y] of movements) {
|
||||
await page.mouse.move(x, y, { steps: 10 })
|
||||
await page.waitForTimeout(16) // ~60fps
|
||||
}
|
||||
|
||||
await page.mouse.up()
|
||||
},
|
||||
{ warmupMs, measureMs }
|
||||
)
|
||||
|
||||
// Clear selection
|
||||
const page = this.getPage()
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
return this.finalizeTestResult('drag_shapes', metrics, expectMinFps)
|
||||
}
|
||||
|
||||
async testShapeResizing(options: InteractionTestOptions = {}): Promise<PerformanceTestResult> {
|
||||
const { warmupMs = 500, measureMs = 3000, expectMinFps } = options
|
||||
|
||||
const { metrics } = await this.fpsTracker.measureInteraction(
|
||||
async () => {
|
||||
const page = this.getPage()
|
||||
|
||||
// Select all shapes for resizing
|
||||
await page.keyboard.press('Control+a')
|
||||
|
||||
// Use editor API to programmatically resize selected shapes
|
||||
await page.evaluate(() => {
|
||||
const editor: Editor = (window as any).editor
|
||||
if (!editor) return
|
||||
|
||||
// Simple seeded random number generator for consistent testing
|
||||
let seed = 12345
|
||||
function seededRandom() {
|
||||
seed = (seed * 9301 + 49297) % 233280
|
||||
return seed / 233280
|
||||
}
|
||||
|
||||
let resizeCycle = 0
|
||||
const resizeInterval = setInterval(() => {
|
||||
const selectedShapes = editor.getSelectedShapes()
|
||||
if (selectedShapes.length === 0) return
|
||||
|
||||
// Generate seeded random scale factors
|
||||
let scaleFactor: number
|
||||
if (resizeCycle % 2 === 0) {
|
||||
// Scale up: random between 1.1 and 5
|
||||
scaleFactor = 1.1 + seededRandom() * (2.0 - 1.1)
|
||||
} else {
|
||||
// Scale down: random between 0.2 and 0.9
|
||||
scaleFactor = 0.5 + seededRandom() * (0.9 - 0.5)
|
||||
}
|
||||
|
||||
// Get current bounds for the scale origin
|
||||
const bounds = editor.getSelectionPageBounds()
|
||||
if (bounds) {
|
||||
// Resize each selected shape individually
|
||||
selectedShapes.forEach((shape: any) => {
|
||||
editor.resizeShape(
|
||||
shape.id,
|
||||
{ x: scaleFactor, y: scaleFactor },
|
||||
{
|
||||
scaleOrigin: bounds.center,
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
resizeCycle++
|
||||
}, 16) // Resize every 16ms for ~60fps
|
||||
|
||||
;(window as any).__resizeInterval = resizeInterval
|
||||
})
|
||||
},
|
||||
{ warmupMs, measureMs }
|
||||
)
|
||||
|
||||
// Stop the resize interval
|
||||
const page = this.getPage()
|
||||
await page.evaluate(() => {
|
||||
const interval = (window as any).__resizeInterval
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
delete (window as any).__resizeInterval
|
||||
}
|
||||
})
|
||||
|
||||
// Clear selection
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
return this.finalizeTestResult('resize_shapes', metrics, expectMinFps)
|
||||
}
|
||||
|
||||
async testCanvasPanning(options: InteractionTestOptions = {}): Promise<PerformanceTestResult> {
|
||||
const { warmupMs = 500, measureMs = 3000, expectMinFps } = options
|
||||
|
||||
const { metrics } = await this.fpsTracker.measureInteraction(
|
||||
async () => {
|
||||
const page = this.getPage()
|
||||
|
||||
// Pan around the canvas using Editor camera API
|
||||
await page.evaluate(() => {
|
||||
const editor: Editor = (window as any).editor
|
||||
if (!editor) return
|
||||
|
||||
// Get initial camera position
|
||||
const initialCamera = editor.getCamera()
|
||||
let panCycle = 0
|
||||
|
||||
// Define pan movements as offsets from initial position
|
||||
const panOffsets = [
|
||||
{ x: -1800, y: -200 },
|
||||
{ x: 400, y: -1800 },
|
||||
{ x: 800, y: 1200 },
|
||||
{ x: -900, y: 800 },
|
||||
{ x: 0, y: 0 },
|
||||
{ x: -3600, y: -3600 },
|
||||
{ x: -900, y: -1900 },
|
||||
{ x: -900, y: 1200 },
|
||||
{ x: 300, y: 800 },
|
||||
{ x: -1800, y: -800 },
|
||||
{ x: 400, y: -1800 },
|
||||
{ x: 800, y: 1200 },
|
||||
{ x: -900, y: 800 },
|
||||
{ x: 0, y: 0 },
|
||||
{ x: -3600, y: -3600 },
|
||||
{ x: -900, y: -1900 },
|
||||
{ x: -900, y: 1200 },
|
||||
{ x: 300, y: 800 },
|
||||
]
|
||||
|
||||
const panInterval = setInterval(() => {
|
||||
const offsetIndex = panCycle % panOffsets.length
|
||||
const offset = panOffsets[offsetIndex]
|
||||
|
||||
// Set camera position to initial position plus offset
|
||||
editor.setCamera({
|
||||
x: initialCamera.x + offset.x,
|
||||
y: initialCamera.y + offset.y,
|
||||
z: initialCamera.z,
|
||||
})
|
||||
|
||||
panCycle++
|
||||
}, 16) // Update every 16ms for ~60fps
|
||||
|
||||
;(window as any).__panInterval = panInterval
|
||||
})
|
||||
},
|
||||
{ warmupMs, measureMs }
|
||||
)
|
||||
|
||||
// Stop the pan interval
|
||||
const page = this.getPage()
|
||||
await page.evaluate(() => {
|
||||
const interval = (window as any).__panInterval
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
delete (window as any).__panInterval
|
||||
}
|
||||
})
|
||||
|
||||
return this.finalizeTestResult('canvas_panning', metrics, expectMinFps)
|
||||
}
|
||||
|
||||
async testCanvasZooming(options: InteractionTestOptions = {}): Promise<PerformanceTestResult> {
|
||||
const { warmupMs = 500, measureMs = 10000, expectMinFps } = options // Shorter measure time for zooming
|
||||
|
||||
const { metrics } = await this.fpsTracker.measureInteraction(
|
||||
async () => {
|
||||
const page = this.getPage()
|
||||
|
||||
// Use editor API to zoom in and out continuously
|
||||
await page.evaluate(() => {
|
||||
const editor: Editor = (window as any).editor
|
||||
if (!editor) return
|
||||
|
||||
let zoomCycle = 0
|
||||
const zoomInterval = setInterval(() => {
|
||||
const cycle = Math.floor(zoomCycle / 10) % 2 // Switch every 10 steps
|
||||
if (cycle === 0) {
|
||||
editor.zoomIn() // Zoom in phase
|
||||
} else {
|
||||
editor.zoomOut() // Zoom out phase
|
||||
}
|
||||
zoomCycle++
|
||||
}, 16) // Zoom every 16ms
|
||||
|
||||
;(window as any).__zoomInterval = zoomInterval
|
||||
})
|
||||
},
|
||||
{ warmupMs, measureMs }
|
||||
)
|
||||
|
||||
// Stop the zoom interval
|
||||
const page = this.getPage()
|
||||
await page.evaluate(() => {
|
||||
const interval = (window as any).__zoomInterval
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
delete (window as any).__zoomInterval
|
||||
}
|
||||
})
|
||||
|
||||
return this.finalizeTestResult('canvas_zooming', metrics, expectMinFps)
|
||||
}
|
||||
|
||||
async testTextShapeCreation(
|
||||
options: InteractionTestOptions = {}
|
||||
): Promise<PerformanceTestResult> {
|
||||
const { warmupMs = 500, measureMs = 10000, expectMinFps } = options
|
||||
|
||||
const { metrics } = await this.fpsTracker.measureInteraction(
|
||||
async () => {
|
||||
const page = this.getPage()
|
||||
|
||||
// Repeatedly create text shapes and frames during the measurement
|
||||
// window. Each cycle deletes the previous batch so text layouts
|
||||
// can't be cached, forcing TextManager.measureText /
|
||||
// measureTextSpans to run every time. The samples mix ASCII with
|
||||
// emoji ZWJ sequences, flags, and accents so the grapheme
|
||||
// segmenter path is exercised. See
|
||||
// https://github.com/tldraw/tldraw/issues/9112.
|
||||
await page.evaluate(() => {
|
||||
const editor = (window as any).editor
|
||||
const tldrawApi = (window as any).tldrawApi
|
||||
if (!editor || !tldrawApi) return
|
||||
|
||||
const textSamples = [
|
||||
'Performance text measurement',
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing',
|
||||
'Hello 👨👩👧 family',
|
||||
'Flag 🇫🇷 with accents café résumé',
|
||||
'Multi-line\ntext content\nwith line breaks',
|
||||
'Emoji 👍🏽 skin tone modifier 🎉 party',
|
||||
]
|
||||
|
||||
// Seeded RNG for reproducible layouts.
|
||||
let seed = 9112
|
||||
const next = () => {
|
||||
seed = (seed * 9301 + 49297) % 233280
|
||||
return seed / 233280
|
||||
}
|
||||
const range = (min: number, max: number) => Math.floor(next() * (max - min + 1)) + min
|
||||
const choice = <T>(arr: T[]): T => arr[range(0, arr.length - 1)]
|
||||
const sizes = ['s', 'm', 'l', 'xl']
|
||||
|
||||
let batch = 0
|
||||
const createInterval = setInterval(() => {
|
||||
const prev = editor.getCurrentPageShapes()
|
||||
if (prev.length) {
|
||||
editor.deleteShapes(prev.map((s: any) => s.id))
|
||||
}
|
||||
|
||||
const shapes: any[] = []
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const sample = `${choice(textSamples)} ${i}`
|
||||
shapes.push({
|
||||
id: `shape:text-${batch}-${i}`,
|
||||
type: 'text',
|
||||
x: range(0, 1600),
|
||||
y: range(0, 1000),
|
||||
props: {
|
||||
richText: tldrawApi.toRichText(sample),
|
||||
size: choice(sizes),
|
||||
font: 'draw',
|
||||
},
|
||||
})
|
||||
}
|
||||
// Frames whose names end in emoji/accents exercise the
|
||||
// title measurement path from the original bug report.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
shapes.push({
|
||||
id: `shape:frame-${batch}-${i}`,
|
||||
type: 'frame',
|
||||
x: range(0, 1600),
|
||||
y: range(0, 1000),
|
||||
props: { w: 320, h: 220, name: `Frame ${choice(textSamples)}` },
|
||||
})
|
||||
}
|
||||
|
||||
editor.createShapes(shapes)
|
||||
batch++
|
||||
}, 100)
|
||||
|
||||
;(window as any).__textCreateInterval = createInterval
|
||||
})
|
||||
},
|
||||
{ warmupMs, measureMs }
|
||||
)
|
||||
|
||||
// Stop the creation interval and clean up.
|
||||
const page = this.getPage()
|
||||
await page.evaluate(() => {
|
||||
const interval = (window as any).__textCreateInterval
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
delete (window as any).__textCreateInterval
|
||||
}
|
||||
})
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
return this.finalizeTestResult('create_text_shapes', metrics, expectMinFps)
|
||||
}
|
||||
|
||||
async runFullPerformanceTest(): Promise<PerformanceTestResult[]> {
|
||||
const results: PerformanceTestResult[] = []
|
||||
|
||||
// Run all performance tests
|
||||
await this.setupHeavyBoard()
|
||||
results.push(await this.testShapeRotation())
|
||||
await this.setupHeavyBoard()
|
||||
results.push(await this.testShapeDragging())
|
||||
await this.setupHeavyBoard(50)
|
||||
results.push(await this.testShapeResizing())
|
||||
await this.setupHeavyBoard(1000)
|
||||
results.push(await this.testCanvasPanning())
|
||||
await this.setupHeavyBoard()
|
||||
results.push(await this.testCanvasZooming())
|
||||
results.push(await this.testTextShapeCreation())
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
updateBaseline(interaction: string, metrics: FPSMetrics, force = false): void {
|
||||
this.baselineManager.updateBaseline(interaction, metrics, this.environment, force)
|
||||
}
|
||||
|
||||
getBaseline(interaction: string) {
|
||||
return this.baselineManager.getBaseline(interaction, this.environment)
|
||||
}
|
||||
|
||||
setThresholds(regressionThreshold: number, warningThreshold: number): void {
|
||||
this.baselineManager.setThresholds(regressionThreshold, warningThreshold)
|
||||
}
|
||||
|
||||
// Helper method to control baseline auto-creation
|
||||
async runTestWithBaseline(
|
||||
testFn: () => Promise<{ metrics: FPSMetrics }>,
|
||||
interaction: string,
|
||||
autoCreate = true
|
||||
): Promise<PerformanceTestResult> {
|
||||
const { metrics } = await testFn()
|
||||
const comparison = this.baselineManager.compareWithBaseline(
|
||||
interaction,
|
||||
metrics,
|
||||
this.environment,
|
||||
autoCreate
|
||||
)
|
||||
|
||||
return {
|
||||
interaction,
|
||||
metrics,
|
||||
comparison,
|
||||
environment: this.environment,
|
||||
}
|
||||
}
|
||||
|
||||
// Common method for finalizing performance test results
|
||||
private finalizeTestResult(
|
||||
interaction: string,
|
||||
metrics: FPSMetrics,
|
||||
expectMinFps?: number
|
||||
): PerformanceTestResult {
|
||||
const comparison = this.baselineManager.compareWithBaseline(
|
||||
interaction,
|
||||
metrics,
|
||||
this.environment
|
||||
)
|
||||
|
||||
// Validate minimum FPS if specified
|
||||
if (expectMinFps && metrics.minFps < expectMinFps) {
|
||||
comparison.status = 'fail'
|
||||
comparison.message += ` Minimum FPS ${metrics.minFps} below expected ${expectMinFps}.`
|
||||
}
|
||||
|
||||
return {
|
||||
interaction,
|
||||
metrics,
|
||||
comparison,
|
||||
environment: this.environment,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get page reference from fps tracker
|
||||
private getPage(): Page {
|
||||
return (this.fpsTracker as any).page as Page
|
||||
}
|
||||
|
||||
// Method to manually create/update baselines
|
||||
async establishBaselines(): Promise<void> {
|
||||
await this.setupHeavyBoard()
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('🔄 Establishing performance baselines...')
|
||||
|
||||
const interactions = [
|
||||
{ name: 'rotate_shapes', testFn: () => this.testShapeRotation() },
|
||||
{ name: 'drag_shapes', testFn: () => this.testShapeDragging() },
|
||||
{ name: 'resize_shapes', testFn: () => this.testShapeResizing() },
|
||||
{ name: 'canvas_panning', testFn: () => this.testCanvasPanning() },
|
||||
{ name: 'canvas_zooming', testFn: () => this.testCanvasZooming() },
|
||||
{ name: 'create_text_shapes', testFn: () => this.testTextShapeCreation() },
|
||||
]
|
||||
|
||||
for (const { name, testFn } of interactions) {
|
||||
const result = await testFn()
|
||||
this.updateBaseline(name, result.metrics, true)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`✅ Baseline set for ${name}: ${result.metrics.averageFps} FPS`)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('🎉 All baselines established!')
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to set up page for performance testing
|
||||
export async function setupPerformanceTest(
|
||||
{ page }: PlaywrightTestArgs,
|
||||
browserName: string
|
||||
): Promise<PerformanceTestSuite> {
|
||||
const perfSuite = new PerformanceTestSuite(page, browserName)
|
||||
await perfSuite.initialize(page)
|
||||
return perfSuite
|
||||
}
|
||||
|
||||
// Helper function to format performance results for reporting
|
||||
export function formatPerformanceResults(results: PerformanceTestResult[]): string {
|
||||
let report = '\n=== Performance Test Results ===\n'
|
||||
|
||||
for (const result of results) {
|
||||
const { interaction, metrics, comparison } = result
|
||||
const status = comparison.status.toUpperCase()
|
||||
const statusEmoji =
|
||||
comparison.status === 'pass' ? '✅' : comparison.status === 'warning' ? '⚠️' : '❌'
|
||||
|
||||
report += `\n${statusEmoji} ${interaction.replace('_', ' ').toUpperCase()} [${status}]\n`
|
||||
report += ` Average FPS: ${metrics.averageFps} (${comparison.avgFpsChange > 0 ? '+' : ''}${comparison.avgFpsChange.toFixed(1)}%)\n`
|
||||
report += ` Min FPS: ${metrics.minFps}, Max FPS: ${metrics.maxFps}\n`
|
||||
report += ` Samples: ${metrics.samples.length}, Duration: ${metrics.duration.toFixed(1)}s\n`
|
||||
report += ` ${comparison.message}\n`
|
||||
}
|
||||
|
||||
const failCount = results.filter((r) => r.comparison.status === 'fail').length
|
||||
const warningCount = results.filter((r) => r.comparison.status === 'warning').length
|
||||
const passCount = results.filter((r) => r.comparison.status === 'pass').length
|
||||
|
||||
report += `\n=== Summary ===\n`
|
||||
report += ` ✅ Passed: ${passCount}\n`
|
||||
report += ` ⚠️ Warnings: ${warningCount}\n`
|
||||
report += ` ❌ Failed: ${failCount}\n`
|
||||
|
||||
return report
|
||||
}
|
||||
Reference in New Issue
Block a user